某コミュニティにて「久々にゲームを作りたい」「じゃあスイカゲーム作れよ」という話になり、すいかゲームを作ることになりました。落ちたすいかが跳ねたり転がったりをどう表現すればいいのかわからず困っていたところ、Cannon.js の存在を知りました。
Cannon.js はオープンソースの JavaScript 3D物理エンジンです。Three.js (WebGL) と組み合わせて使われることが多いのですが、ここでは 3D ではなく 2D で、しかも Three.js は使わずにゲームを作ります。Cannon.js でやることはボールの座標の計算です。
HTML部分
それでは 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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかるすいかゲーム</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.js"></script> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <div id = "field"> <div> <canvas id = "canvas"></canvas> </div> </div> <div id = "start-buttons"> <div class = "center"> <p><button class = "ctrl-button_" id = "start">スタート</button></p> <p>プレイヤー名:<br><input type="text" id = "player-name"></p> <p><a href="./ranking.html">スコアランキングへ</a></p> </div> </div> <button class = "ctrl-button" id = "left">左</button> <button class = "ctrl-button" id = "right">右</button> <button class = "ctrl-button" id = "shot">投下</button> <p>PCなら ← → ↓ キーでも操作できます。</p> <div> 音量: <input type="range" id = "volume-range"> <span id = "volume-text">0</span> </div> <button id = "volume-test">音量テスト</button> </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 63 64 65 66 67 68 |
body { background-color: black; color: white; } #container { width: 360px; } #field { position: relative; } .ctrl-button { position: absolute; width: 100px; height: 70px; background-color: transparent; color: white; font-weight: bold; } #start-buttons { width: 350px; height: 250px; position: absolute; left: 10px; top: 190px; color: white; } #start { width: 140px; height: 70px; left: 120px; top: 300px; } #left { left: 30px; top: 350px; display: none; } #shot { left: 140px; top: 350px; display: none; } #right { left: 250px; top: 350px; display: none; } .center { text-align: center; } a { color: aqua; font-weight: bold; } a:hover { color: red; } #volume-text { margin-left: 20px; } #volume-range { width: 200px; vertical-align: middle; } #volume-test { width: 100px; height: 50px; } |
グローバル変数と定数
グローバル変数と定数を示します。
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 |
const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 480; const $canvas = document.getElementById('canvas'); const ctx = $canvas.getContext('2d'); const $shot = document.getElementById('shot'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); const $start = document.getElementById('start'); const $startButtons = document.getElementById('start-buttons'); const cannon_world = new CANNON.World() const Y_MIN = 30; // 地面の高さ const X_MIN = 60; // 左右の壁の X 座標 const X_MAX = CANVAS_WIDTH - X_MIN; const DEAD_LINE = 380; // ボールがこれより高くなったらゲームオーバー const Y_START = 420; // ボールはこの高さから落とされる const shotInterval = 30; // 30更新したら次のボールを落とすことができる // ボールに描画されるイメージと背景色、使用する画像ファイルのパス const images = []; const colors = ['#f00', '#f00', '#ff4500', '#ff0', '#f0f', '#0ff', '#0f0',]; const filePaths = ['./images/1.png', './images/2.png', './images/3.png', './images/4.png', './images/5.png', ]; let balls = []; // 現在フィールドに存在するボールのオブジェクト let nextX = 150; // 次のボールが表示される X 座標 と半径 let nextRadius = 20; let isPressLeftKey = false; // 左右の移動ボタンは押下されているか? let isPressRightKey = false; // ボールが投下されたり消えたあとの更新回数 let updateCountAfterShot = 0; let updateCountAfterDelete = 0; // 更新処理は停止しているかどうか? let isUpdateStoped = false; // スコアと現在のレベル let score = 0; let level = 0; // 効果音関連 const gameoverSound = new Audio('./sounds/gameover.mp3'); const deleteSound = new Audio('./sounds/delete.mp3'); const shotSound = new Audio('./sounds/shot.mp3'); let volume = 0.3; |
Ballクラスの定義
投下されたボールの座標を取得し描画するためのBallクラスを定義します。
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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
class Ball{ constructor(x, y, r){ this.Radius = r; const body = new CANNON.Body( { mass: this.Radius, shape: new CANNON.Sphere(r), position: new CANNON.Vec3( x, y, 0 ), velocity: new CANNON.Vec3( 0, -50, 0 ), // 下向き 初速 50 で投げつける material: new CANNON.Material( { friction: 0.2, // 摩擦係数 restitution: 0.1, // 反発係数 } ), } ); cannon_world.add(body); this.Body = body; this.IsRemoved = false; this.Angle = 0; // ボールの回転 } Draw(){ ctx.fillStyle = getBallColor(this.Radius); // ボールの背景色を取得(後述) ctx?.beginPath(); const y = CANVAS_HEIGHT - this.Body.position.y; ctx?.arc(this.Body.position.x, y, this.Radius, 0, Math.PI * 2); ctx?.fill(); // ボールが移動するときに回転させる if(this.Body.velocity.x == 0) this.Angle += this.Body.velocity.y / 2000; else this.Angle += this.Body.velocity.x / 500; // ボールのうえにイメージを描画する(後述) drawBallImage(this.Body.position.x, y, this.Radius, this.Angle); } // ボールの座標、速度を取得 GetX(){ return this.Body.position.x; } GetY(){ return this.Body.position.y; } GetVelocity(){ const v2 = Math.pow(this.Body.velocity.x, 2) + Math.pow(this.Body.velocity.y, 2); return Math.sqrt(v2); } // ボールを消滅させる Remove(){ cannon_world.removeBody(this.Body); this.IsRemoved = true; } // ボールを 5 大きくする(一度 removeBody してから追加しなおさないとダメっぽい) Big(){ const x = this.Body.position.x; const y = this.Body.position.y; cannon_world.removeBody(this.Body); this.Radius += 5; const body = new CANNON.Body( { mass: this.Radius, // 半径が大きなものは重くして重力の影響を受けやすくする shape: new CANNON.Sphere(this.Radius), position: new CANNON.Vec3( x, y, 0 ), material: new CANNON.Material( { friction: 0.2, restitution: 0.1, // 反発係数 } ), } ); cannon_world.add(body); this.Body = body; this.Angle = 0; } } |
drawBallImage関数はボールの半径に応じてボールに描画されるイメージを描画する関数です。getBallColor関数は半径に応じた色を、getBallImage関数は半径に応じたイメージを取得します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function drawBallImage(centerX, centerY, radius, angle){ const width = radius / 1.4 + 5; const image = getBallImage(radius); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(angle); ctx.drawImage(image, -width, -width, width * 2, width * 2); ctx.restore(); } function getBallColor(radius){ let index = (radius - 15) / 5; if(index > colors.length - 1) index = colors.length - 1; return colors[index]; } function getBallImage(radius){ let index = (radius - 15) / 5; if(index > images.length - 1) index = images.length - 1; return images[index]; } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
重力加速度を設定して、地面、左右の壁を生成して CANNON.World に追加します。また画像ファイルのパスからイメージを生成して配列 images に格納します。最初に投下されるボールの半径を決定して nextRadius に格納します。そのあとイベントリスナの追加、レンジスライダーでボリューム設定を可能にする処理をおこない更新処理をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
window.onload = () => { cannon_world.gravity.set( 0, -9.8, 0 ); createField(); // 地面、左右の壁を生成(後述) for(let i = 0; i < filePaths.length; i++){ const image = new Image(); image.src = filePaths[i]; images.push(image); } const arr = [15, 20, 25, ]; const index = Math.floor(Math.random() * arr.length); nextRadius = arr[index]; addEventListeners(); // イベントリスナの追加(後述) initVolume(); // ボリューム設定を可能にする処理(後述) update(); // 更新処理(後述) } |
地面、左右の壁を生成して CANNON.World に追加する処理を示します。
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 |
function createField(){ const ground_body = new CANNON.Body( { mass: 0, // 質量 0 なら重力の影響をうけない shape: new CANNON.Box( new CANNON.Vec3( 400, 2, 400 ) ), position: new CANNON.Vec3( 0, Y_MIN - 1, 0 ), material: new CANNON.Material( { friction: 0.7,// 摩擦係数 restitution: 0.5, // 反発係数 } ), } ); cannon_world.add( ground_body ); const left_wall_body = new CANNON.Body( { mass: 0, shape: new CANNON.Box( new CANNON.Vec3( 2, 1000, 1000 ) ), position: new CANNON.Vec3( X_MIN - 1, 500, 0 ), material: new CANNON.Material( { friction: 0.7, restitution: 0.5, } ), } ); cannon_world.add(left_wall_body); const right_wall_body = new CANNON.Body( { mass: 0, // 質量 0 なら重力の影響をうけない shape: new CANNON.Box( new CANNON.Vec3( 2, 1000, 1000 ) ), position: new CANNON.Vec3( X_MAX + 1, 500, 0 ), material: new CANNON.Material( { friction: 0.7, restitution: 0.5, } ), } ); cannon_world.add(right_wall_body); } |
イベントリスナを追加する処理を示します。
キーやボタンを押下したらボールを投下したり、次に投下するボールをその方向に移動させるフラグをセットしたりクリアする処理を定義しています。
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 |
function addEventListeners(){ $start.addEventListener('click', () => gameStart()); $shot.addEventListener('click', () => shot()); // gameStart 関数、shot 関数は後述 const arr1 = ['mousedown', 'touchstart']; const arr2 = ['mouseup', 'touchend']; for(let i = 0; i < arr1.length; i++){ $left.addEventListener(arr1[i], (ev) => { isPressLeftKey = true; ev.preventDefault(); }); $right.addEventListener(arr1[i], (ev) => { isPressRightKey = true; ev.preventDefault(); }); } for(let i = 0; i < arr2.length; i++){ $left.addEventListener(arr2[i], (ev) => { isPressLeftKey = false; ev.preventDefault(); }); $right.addEventListener(arr2[i], (ev) => { isPressRightKey = false; ev.preventDefault(); }); } document.onkeydown = (ev) => { // $startButtons が表示されているということはゲーム開始前。この場合はなにもしない。 // ゲーム開始以降はデフォルトの動作を抑止する if($startButtons.style.display != 'none') return; if(ev.key == 'ArrowLeft'){ ev.preventDefault(); isPressLeftKey = true; } if(ev.key == 'ArrowRight'){ ev.preventDefault(); isPressRightKey = true; } if(ev.key == 'ArrowDown'){ ev.preventDefault(); shot(); } } document.onkeyup = (ev) => { if(ev.key == 'ArrowLeft') isPressLeftKey = false; if(ev.key == 'ArrowRight') isPressRightKey = false; } } |
以下はレンジスライダーでボリューム設定を可能にする定番の処理です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function initVolume(){ const $volumeRange = document.getElementById('volume-range'); const $volumeText = document.getElementById('volume-text'); $volumeRange.addEventListener('input', () => { const value = $volumeRange.value; $volumeText.innerText = value; volume = value / 100; setVolume(); }); setVolume(); $volumeText.innerText = volume * 100; $volumeRange.value = volume * 100; function setVolume(){ console.log('aaa') gameoverSound.volume = volume; deleteSound.volume = volume; shotSound.volume = volume; } const $volumeTest = document.getElementById('volume-test'); $volumeTest.addEventListener('click', () => deleteSound.play()); } |
ゲーム開始時の処理や更新処理は次回とします。