最近競技プログラミングネタばっかりだったので、久々にゲームをつくります。ゲームのルールですが、自機を操作して領域を囲い込み、全体の75%を囲い込んだらクリアです。敵が自機や自機の軌跡に触れたらミスとなります。ただし自機の軌跡であっても囲い込まれた領域の外周であればミスにならないというルールにします。
ちょっと言葉では説明しにくいですが、こんな感じのゲームです。
基本的な仕様
自機は静止することができず、つねに上下左右4方向のいずれかに移動しつづける。
8ピクセルごとに格子点を設置し、自機がここにいるときのみ方向転換可能とする。
自機が移動したら格子点間を赤い線で結ぶ。線で閉路が構成されたときは閉路を構成する赤い線を白い線に変更し、内部の領域を青く塗りつぶす。自機や敵は青い領域内に進入することはできない。
自機や赤い線に敵が触れたときはミスとする。白い線に触れた敵は破壊される。
青い領域が全体の75%を超えたらステージクリアとする。
だいたいこんな感じです。では作っていきましょう。
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>Enclosing Space</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <div id = "field"> <div id = "field-header"> <h1>Enclosing Space</h1> <div id = "score">Score 0</div><div id = "life"></div> </div> <div id = "canvas-outer"></div> <div id = "start-buttons"> <p><label for="player-name">プレイヤー名:</label> <input id = "player-name" maxlength="32"></p> <p><button id = "start">START</button></p> <p><button id = "go-ranking" onclick="location.href = './ranking.html'">ランキング</button></p> </div> <div id = "control-buttons"> <button id = "left">←</button> <button id = "up">↑</button> <button id = "down">↓</button> <button id = "right">→</button> </div> <div id = "volume"></div> </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 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
body { background-color: black; color: white; } #container { margin: 40px auto 40px auto; width: 360px; } #field-header { margin-left: 10px; margin-bottom: 45px; } #field { width: 360px; height: 600px; margin-left: auto; margin-right: auto; position: relative; border: 0px #1DA1F2 solid; } #score { float: left; font-size: large; margin-left: 10px; } #life { float: right; font-size: large; margin-right: 16px; } .ml-10 { margin-left: 10px; } .ml-20 { margin-left: 20px; } h1 { color:aqua; font-size: x-large; } #start-buttons { position: absolute; left: 0px; top: 130px; width: 100%; text-align: center; } #start, #go-ranking { font-weight: bold; text-align: center; font-size: 18px; width: 280px; border: none; font-size: 16px; background-color: #1DA1F2; padding: 10px 32px; border-radius: 100vh; color: white; cursor: pointer; } #control-buttons { width: 100%; text-align: center; left: 0px; top: 380px; display: block; } #up, #left, #right, #down { width: 64px; height: 60px; background-color: transparent; border: 2px #fff solid; margin-left: 5px; margin-right: 5px; font-size: 18px; color: #fff; font-weight: bold; } |
グローバル定数
グローバル定数は以下のとおりです。
index.js
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const CELL_SIZE = 8; // 格子点の間の距離 const PLAYER_SIZE = 32; // 自機の大きさ const PLAYER_MIN = 32; // 自機が移動できる範囲(MIN、MAX ともに CELL_SIZE の倍数でなければならない) const PLAYER_MAX = 360 - 32; const CANVAS_WIDTH = 360; const CANVAS_HEIGHT = 360; const $canvas_outer = document.getElementById('canvas-outer'); const $canvas = document.createElement('canvas'); $canvas.width = CANVAS_WIDTH; $canvas.height = CANVAS_HEIGHT; $canvas_outer.appendChild($canvas); const ctx = $canvas.getContext('2d'); |
Vertexクラスの定義
格子点を管理するためにVertexクラスを定義します。
|
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 |
class Vertex { constructor(x, y){ this.X = x; // 格子点の座標 this.Y = y; this.IsVisited = false; // 自機が訪問済み? // 格子点よりもすこしだけ左上、右上、左下、右下はすでに囲い込まれているか? this.IsCoverdNW = false; this.IsCoverdNE = false; this.IsCoverdSW = false; this.IsCoverdSE = false; // 線分でつながっている格子点のリスト this.Nexts = []; } // 格子点は囲い込まれた領域の「内部」に存在する IsCoveredCompletely() { return (this.IsCoverdNE && this.IsCoverdNW && this.IsCoverdSE && this.IsCoverdSW); } // 格子点は囲い込まれた領域の「内部」または「境界」に存在する IsCovered(){ return (this.IsCoverdNE || this.IsCoverdNW || this.IsCoverdSE || this.IsCoverdSW); } } |
Playerクラスの定義
自機の移動や描画処理をするためにPlayerクラスを定義します。
|
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 |
class Player { constructor(){ this.CX = 0; // 自機の中心座標 this.CY = 0; this.Direct = 'N'; // 自機の移動方向 this.NextDirect = 'N'; // 自機の次の移動方向 this.IsDead = false; this.LastVertex = null; // 自機が最後に訪問した格子点 // 描画用のイメージ this.ImageLeft = new Image(); this.ImageLeft.src = './images/player-left.png'; this.ImageRight = new Image(); this.ImageRight.src = './images/player-right.png'; this.ImageUp = new Image(); this.ImageUp.src = './images/player-up.png'; this.ImageDown = new Image(); this.ImageDown.src = './images/player-down.png'; this.Image = this.ImageLeft; } // 自機を初期位置に戻す Init(sx, sy){ this.Direct = 'N'; this.NextDirect = 'N'; this.IsDead = false; if(sx == undefined && sy == undefined){ this.CX = Math.floor((CANVAS_WIDTH / 2) / CELL_SIZE) * CELL_SIZE; this.CY = Math.floor((CANVAS_HEIGHT / 2) / CELL_SIZE) * CELL_SIZE; } else { this.CX = sx; this.CY = sy; } this.LastVertex = null; } // 描画処理 Draw(){ if(this.Direct == 'L') this.Image = this.ImageLeft; if(this.Direct == 'R') this.Image = this.ImageRight; if(this.Direct == 'U') this.Image = this.ImageUp; if(this.Direct == 'D') this.Image = this.ImageDown; // 死亡していなければ描画する if(!this.IsDead) ctx.drawImage(this.Image, this.CX - PLAYER_SIZE / 2, this.CY - PLAYER_SIZE / 2, PLAYER_SIZE, PLAYER_SIZE); } } |
Enemyクラスの定義
敵の移動や描画処理をするためにEnemyクラスを定義します。
|
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 |
class Enemy { constructor(x, y, vx, vy){ this.CX = x; this.CY = y; this.VX = vx; this.VY = vy; // 色を決める const rand = Math.random(); if(rand < 0.25) this.Color = '#ff0'; else if(rand < 0.5) this.Color = '#f0f'; else if(rand < 0.75) this.Color = '#0ff'; else this.Color = '#0f0'; this.UpdateCount = 0; this.IsDead = false; this.Image = new Image(); } Move(){ this.CX += this.VX; this.CY += this.VY; this.UpdateCount++; } // 回転する星型の物体を描画する Draw(){ ctx.fillStyle = this.Color; const x = []; const y = []; for(let i = 0; i < 5; i++){ const rad = 2 * Math.PI * i / 5 + this.UpdateCount / 128; x.push(this.CX + 10 * Math.cos(rad)); y.push(this.CY + 10 * Math.sin(rad)); } ctx.beginPath(); ctx.moveTo(x[0], y[0]); ctx.lineTo(x[2], y[2]); ctx.lineTo(x[4], y[4]); ctx.lineTo(x[1], y[1]); ctx.lineTo(x[3], y[3]); ctx.lineTo(x[0], y[0]); ctx.closePath(); ctx.fill(); } } |
Sparkクラスの定義
爆発時の火花の移動や描画処理をするためにSparkクラスを定義します。
|
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 |
class Spark { constructor(x, y, vx, vy){ this.X = x; this.Y = y; this.VX = vx; this.VY = vy; this.Life = 16; } Move(){ this.X += this.VX; this.Y += this.VY; this.Life--; } Draw(){ if(this.Life > 0){ ctx.beginPath(); ctx.arc(this.X, this.Y, 4, 0, Math.PI * 2); ctx.closePath(); ctx.fillStyle = '#fff'; ctx.shadowBlur = 8; ctx.shadowColor = '#fff'; ctx.fill(); ctx.shadowBlur = 0; } } } |
Gameクラスの定義
Gameクラスを定義します。
初期化に関する処理
コンストラクタと関連する関数を示します。
最初にコンストラクタを示します。
|
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 |
class Game { constructor(){ this.$score = document.getElementById('score'); this.$life = document.getElementById('life'); this.IsPlaying = false; // 現在プレイ中か? this.StopingUpdate = true; // 更新処理は停止しているか? this.Score = 0; // スコア、現在のステージ、残機数 this.Stage = 1; this.InitLife = 5; this.Life = this.InitLife; this.VerticesMap = this.CreateVerticesMap(); // 格子点の生成 let cnt = 0; for(let v of this.VerticesMap.values()){ if(v.X >= PLAYER_MIN && v.X < PLAYER_MAX && v.Y >= PLAYER_MIN && v.Y < PLAYER_MAX) cnt++; } this.CellCount = cnt; // 格子点の数 this.Rate = 0; // 全体のどれだけ囲い込めたか? this.Player = new Player(); this.Player.Init(); this.Enemies = []; this.Sparks = []; this.UpdateCount = 0; this.Draw(); // 描画処理をする(後述) // 効果音 this.BGM = new Audio('./sounds/bgm.mp3'); this.SoundSelect = new Audio('./sounds/select.mp3'); this.SoundStageClear = new Audio('./sounds/stage-clear.mp3'); this.SoundPlayerDead = new Audio('./sounds/dead.wav'); this.SoundEnemyDead = new Audio('./sounds/enemy-dead.mp3'); this.SoundGameover = new Audio('./sounds/gameover.mp3'); this.Sounds = [ this.SoundSelect, this.SoundStageClear, this.SoundPlayerDead, this.SoundEnemyDead, this.SoundGameover, this.BGM, ]; // 最後に更新処理が行われた時刻を記録しておく this.PrevUpdateTime = Date.now(); } } |
格子点を初期化する処理を示します。自機が移動できる範囲に CELL_SIZE ピクセルごとに格子点を置き、座標からそのオブジェクトを取得できるようにしておきます。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
class Game { CreateVerticesMap(){ const map = new Map(); for(let y = PLAYER_MIN; y <= PLAYER_MAX; y += CELL_SIZE){ for(let x = PLAYER_MIN; x <= PLAYER_MAX; x += CELL_SIZE){ const v = new Vertex(x, y); map.set(`${x},${y}`, v); } } return map; } } |
GetVertex関数は座標からVertexオブジェクトを取得するための関数です。
|
1 2 3 4 5 6 |
class Game { GetVertex(x, y){ const key = `${x},${y}`; return this.VerticesMap.get(key); } } |
ゲーム開始の処理
ゲーム開始時の処理を示します。
|
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 Game { GameStart(){ // スコア、ステージ数、囲い込み率、残機数をリセット this.Score = 0; this.Stage = 1; this.Rate = 0; this.Life = this.InitLife; // 格子点の状態を初期化する this.VerticesMap = this.CreateVerticesMap(); // 自機、敵、火花を初期化する this.Player.Init(); this.Player.Direct = 'L'; this.Player.NextDirect = 'L'; this.Enemies = []; this.Sparks = []; this.UpdateCount = 0; document.getElementById('score').innerHTML = `Score ${this.Score.toLocaleString()}`; // IsPlaying フラグとセットして更新処理を開始する this.IsPlaying = true; this.StopingUpdate = false; // [ゲームスタート]ボタンを非表示にしてBGMを再生する document.getElementById('start-buttons').style.display = 'none'; this.SoundSelect.play(); this.BGM.currentTime = 0; this.BGM.play(); } } |
自機の移動・方向転換
自機の移動したり方向転換するための処理を示します。
SetPlayerDirct関数は自機の次の方向をセットします。自機が格子点(方向転換が可能な場所)に到達したときに実際に方向転換の処理がおこなわれます。
|
1 2 3 4 5 |
class Game { SetPlayerDirct(dir){ this.Player.NextDirect = dir; } } |
MovePlayer関数は自機を移動させ、自機が方向転換可能な位置にいる場合は方向転換の処理をおこないます。また自機が格子点にいるときは現在セットされている方向にこれ以上移動できないかもしれません。その場合は移動可能な方向を取得してそれをランダムにセットします。
また自機が格子点にいる場合、赤い線と白い線で新たに囲い込まれた領域が存在するかもしれません。そこでまず、自機が最後に訪問した格子点と新たに訪問した点がつながっていないならつなぎます。これによってかつて訪問した格子点や囲い込まれている領域の境界とつながったのであれば、閉路が形成された ⇒ 新たに囲い込まれた領域が存在するということなので、OnCreatedCycle関数(後述)を呼び出して囲い込まれた領域を調べます。
|
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
class Game { MovePlayer(){ if(this.Player.IsDead) return; const player = this.Player; const n_dir = this.Player.NextDirect; const ng_dir_set = new Set(); if(player.CX % CELL_SIZE == 0 && player.CY % CELL_SIZE == 0){ // 自機が格子点にいるなら const v = this.GetVertex(player.CX, player.CY); player.LastVertex = v; // 訪問済み格子点として記録する if(!v.IsVisited) v.IsVisited = true; // 移動できない方向をチェック if(v.IsCoverdNE && v.IsCoverdNW) ng_dir_set.add('U'); if(v.IsCoverdSE && v.IsCoverdSW) ng_dir_set.add('D'); if(v.IsCoverdNW && v.IsCoverdSW) ng_dir_set.add('L'); if(v.IsCoverdNE && v.IsCoverdSE) ng_dir_set.add('R'); if(player.CX <= PLAYER_MIN) ng_dir_set.add('L'); if(player.CX >= PLAYER_MAX) ng_dir_set.add('R'); if(player.CY <= PLAYER_MIN) ng_dir_set.add('U'); if(player.CY >= PLAYER_MAX) ng_dir_set.add('D'); // NextDirectが移動不能でない方向ならセットする if(!ng_dir_set.has(n_dir)) player.Direct = n_dir; // player.Direct が移動可能な方向でないなら、別の移動可能な方向をセットする const dir = player.Direct; if(ng_dir_set.has(dir)){ player.Direct = 'N'; let r_dir = ''; if(dir == 'U') r_dir = 'D'; if(dir == 'D') r_dir = 'U'; if(dir == 'L') r_dir = 'R'; if(dir == 'R') r_dir = 'L'; const dirs = ['U', 'R', 'D', 'L']; for(let i = 0; i < 4; i++){ if(!ng_dir_set.has(dirs[i])){ if(dirs[i] == r_dir) continue; player.Direct = dirs[i]; player.NextDirect = dirs[i]; break; } } if(player.Direct == 'N'){ player.Direct = r_dir; player.NextDirect = r_dir; } } } else if(player.CX % CELL_SIZE == 0){ // 上下の方向転換ならできる if(n_dir == 'U' || n_dir == 'D'){ player.Direct = n_dir; } } else if(player.CY % CELL_SIZE == 0){ // 左右の方向転換ならできる if(n_dir == 'L' || n_dir == 'R') player.Direct = n_dir; } // 現在の移動方向からつぎの自機の座標をセットする const dir = player.Direct; if(dir == 'L') player.CX--; if(dir == 'R') player.CX++; if(dir == 'U') player.CY--; if(dir == 'D') player.CY++; // 自機が格子点にいる場合、赤い線と白い線で新たに囲い込まれた領域が存在するかもしれない if(player.CX % CELL_SIZE == 0 && player.CY % CELL_SIZE == 0){ const v = this.GetVertex(player.CX, player.CY); let created_cycle = false; // 自機が最後に訪問した格子点と新たに訪問した点がつながっていないならつなぐ const last_v = player.LastVertex; if(last_v != v){ let find = false; last_v.Nexts.forEach(next => { if(next == v) find = true; }); if(!find){ last_v.Nexts.push(v); // つながっていなかったのでつなぐ v.Nexts.push(last_v); // かつて訪問した格子点や囲い込まれている領域の境界とつながった // ⇒ 閉路が形成された ⇒ 新たに囲い込まれた領域が存在する if(v.IsVisited || v.IsCoverdNE || v.IsCoverdNW || v.IsCoverdSE || v.IsCoverdSW) created_cycle = true; } } v.IsVisited = true; if(created_cycle) this.OnCreatedCycle(v); // 後述 } } } |
閉路検出の処理
自機が格子点に到達したとき、これによって閉路が形成されたら囲い込みの処理をおこないます。
深さ優先探索でつながっている格子点をたどっていき、閉路があるならこれを取得します。閉路は複数存在するので、すべて取得します。
取得された閉路の内部はすでに囲い込まれた領域として認識されている部分もあるし、新たに囲い込まれた領域も含んでいます。そこで差分を調べます。
|
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
class Game { OnCreatedCycle(sv){ const set = new Set(); const temp = []; const paths = []; dfs(sv, null); function dfs(v, par){ set.add(v); temp.push(v); const nexts = v.Nexts; for(let i = 0; i < nexts.length; i++){ if(nexts[i] != par && !set.has(nexts[i])) dfs(nexts[i], v); if(nexts[i] == sv){ console.log('OK'); const path = []; temp.forEach(v => path.push(v)); if(path.length > 0) paths.push(path); } } temp.pop(); } if(paths.length == 0) return; // すでに囲い込まれた領域として認識されている格子点の個数を数える let before_cnt = 0; for(let v of this.VerticesMap.values()){ if(v.IsCoverdSE) before_cnt++; } // 新たに囲い込まれた領域を実際に線分で囲ってみて格子点の周辺が領域内かどうか調べる for(let i = 0; i < paths.length; i++){ const temp_canvas = document.createElement('canvas'); const temp_ctx = temp_canvas.getContext('2d'); const path = paths[i]; if(path.length > 0){ temp_ctx.beginPath(); temp_ctx.moveTo(path[0].X, path[0].Y); for(let idx = 1; idx < path.length; idx++) temp_ctx.lineTo(path[idx].X, path[idx].Y); temp_ctx.closePath(); } // 囲い込まれた領域の周辺は領域の内部かどうか? for(let v of this.VerticesMap.values()){ if(temp_ctx.isPointInPath(v.X + 4, v.Y + 4)) v.IsCoverdSE = true; if(temp_ctx.isPointInPath(v.X - 4, v.Y + 4)) v.IsCoverdSW = true; if(temp_ctx.isPointInPath(v.X + 4, v.Y - 4)) v.IsCoverdNE = true; if(temp_ctx.isPointInPath(v.X - 4, v.Y - 4)) v.IsCoverdNW = true; } } // 現在に囲い込まれた領域として認識されている格子点の個数を数える // この差分から新たに囲いこまれた領域の面積がわかる let after_cnt = 0; for(let v of this.VerticesMap.values()){ if(v.IsCoverdSE) after_cnt++; } // 新たに囲いこまれた領域の面積から加点すべき点数を求める // 増加した面積が大きいほど加点も増やす this.Rate = Math.floor(after_cnt / this.CellCount * 100); this.Score += after_cnt - before_cnt; const diff = after_cnt - before_cnt; if(diff < 20) this.Score += diff; else if(diff < 40) this.Score += diff * 2; else if(diff < 60) this.Score += diff * 4; else if(diff < 80) this.Score += diff * 8; else if(diff < 100) this.Score += diff * 16; else this.Score += diff * 32; // 効果音 if(diff > 0){ this.SoundSelect.currentTime = 0; this.SoundSelect.play(); } // 囲い込まれている領域の内部に向けて張られている辺は必要ないし、 // 処理速度を低下させるだけなので取り除く for(let v of this.VerticesMap.values()){ if(v.IsCoverdSW && v.IsCoverdSE) v.Nexts = v.Nexts.filter(next => next != this.GetVertex(v.X, v.Y + CELL_SIZE)); if(v.IsCoverdNW && v.IsCoverdNE) v.Nexts = v.Nexts.filter(next => next != this.GetVertex(v.X, v.Y - CELL_SIZE)); if(v.IsCoverdNW && v.IsCoverdSW) v.Nexts = v.Nexts.filter(next => next != this.GetVertex(v.X - CELL_SIZE, v.Y)); if(v.IsCoverdNE && v.IsCoverdSE) v.Nexts = v.Nexts.filter(next => next != this.GetVertex(v.X + CELL_SIZE, v.Y)); } } } |
格子点と格子点が繋がっている場合、両者のあいだに線分を描画しなければなりませんが、その色が赤なのか白なのかを調べる処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class Game { GetColor(v1, v2){ if(v1.X == v2.X){ const min = v1.Y < v2.Y ? v1 : v2; const max = v1.Y > v2.Y ? v1 : v2; if(min.IsCoverdSE && max.IsCoverdNE) return 'white'; else if(min.IsCoverdSW && max.IsCoverdNW) return 'white'; else return 'red'; } if(v1.Y == v2.Y){ const min = v1.X < v2.X ? v1 : v2; const max = v1.X > v2.X ? v1 : v2; if(min.IsCoverdNE && max.IsCoverdNW) return 'white'; else if(min.IsCoverdSE && max.IsCoverdSW) return 'white'; else return 'red'; } } } |
ステージクリア判定
ステージクリア判定の処理を示します。
囲い込まれた領域が全体の 75%以上であれば、一時的に描画を停止して、ボーナス点を加算したあと自機と敵の状態をリセットしているだけです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class Game { CheckStageClear(){ if(this.Rate < 75) return; this.StopingUpdate = true; this.SoundStageClear.play(); setTimeout(() => { this.Score += this.Stage * 2000; this.Stage++; this.VerticesMap = this.CreateVerticesMap(); this.Player.Init(); this.Player.Direct = 'L'; this.Player.NextDirect = 'L'; this.Enemies = []; this.UpdateCount = 0; this.Rate = 0; this.StopingUpdate = false; }, 2000); } } |
敵の移動
敵を移動させる処理を示します。
1秒おきに敵の数が 5 + ステージ数 より小さければフィールドの端に敵を出現させ移動させます。そしてフィールドから外にでたら取り除きます。
|
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 |
class Game { MoveEnemies(){ function get_ramdom(min, max){ const rand = Math.random() * (max - min); return rand + min; } function is_inside(enemy){ return enemy.CX > -64 && enemy.CX < CANVAS_WIDTH + 64 && enemy.CY > -64 && enemy.CY < CANVAS_HEIGHT + 64; } if(this.UpdateCount % 60 == 0){ if(this.Enemies.length < 5 + this.Stage){ const rand = Math.random(); let sx = 0; let sy = 0; let rad = 0; if(rand < 0.25){ sx = get_ramdom(-32, CANVAS_WIDTH + 32); sy = -32; rad = get_ramdom(Math.PI * 0.2, Math.PI * 0.8); } else if(rand < 0.5){ sx = get_ramdom(- 32, CANVAS_WIDTH + 32); sy = CANVAS_HEIGHT + 32; rad = get_ramdom(Math.PI * 1.2, Math.PI * 1.8); } else if(rand < 0.75){ sx = -32; sy = get_ramdom(-32, CANVAS_HEIGHT + 32); rad = get_ramdom(Math.PI * (-0.3), Math.PI * 0.3); } else { sx = CANVAS_WIDTH + 32; sy = get_ramdom(-32, CANVAS_HEIGHT + 32); rad = get_ramdom(Math.PI * 0.7, Math.PI * 1.3); } this.Enemies.push(new Enemy(sx, sy, Math.cos(rad) * 1.2, Math.sin(rad) * 1.2)); } } this.Enemies.forEach(enemy => enemy.Move()); this.Enemies = this.Enemies.filter(enemy => is_inside(enemy) && !enemy.IsDead); } } |
火花の生成と移動
火花を生成し移動させる処理を示します。
生成の処理は引数の座標に 16 個のオブジェクトを生成して初速をランダムに設定します。移動は速度に応じて移動させ、Life が 0 になったらリストから削除します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Game { SetSparks(x, y){ for(let i = 0; i < 16; i++){ const rad = Math.random() * 2 * Math.PI; this.Sparks.push(new Spark(x, y, Math.cos(rad), Math.sin(rad))); } } MoveSparks(){ this.Sparks.forEach(spark => spark.Move()); this.Sparks = this.Sparks.filter(spark => spark.Life > 0); } } |
当たり判定
当たり判定の処理を示します。
敵が自機や赤い線に接触しているかどうかを調べています。
|
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 Game { HitJudge(){ const player = this.Player; this.Enemies.forEach(enemy => { if(!enemy.IsDead){ for(let v of this.VerticesMap.values()){ const dist2 = Math.pow(enemy.CX - player.CX, 2) + Math.pow(enemy.CY - player.CY, 2); // 敵が自機と接触した(自機と敵死亡) const player_size_2 = Math.pow(PLAYER_SIZE / 2 - 2, 2); if(dist2 < player_size_2){ enemy.IsDead = true; this.SetSparks(enemy.CX, enemy.CY); this.PlayerDead(); break; } // 敵が囲い込まれている領域の境界と接触した(敵のみ死亡) if(v.IsCovered()){ const d2 = Math.pow(enemy.CX - v.X, 2) + Math.pow(enemy.CY - v.Y, 2); if(d2 < 8 * 8){ enemy.IsDead = true; this.SetSparks(enemy.CX, enemy.CY); this.SoundEnemyDead.currentTime = 0; this.SoundEnemyDead.play(); break; } } // 敵が赤い線と接触した(自機と敵死亡) if(!player.IsDead && !v.IsCovered() && v.IsVisited){ const d2 = Math.pow(enemy.CX - v.X, 2) + Math.pow(enemy.CY - v.Y, 2); if(d2 < 8 * 8){ enemy.IsDead = true; this.SetSparks(enemy.CX, enemy.CY); this.PlayerDead(); break; } } } } }); } } |
自機死亡時の処理
自機死亡時の処理を示します。
自機死亡時に残機があれば残機 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 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 81 82 83 84 85 86 87 88 |
class Game { PlayerDead(){ const player = this.Player; if(player.IsDead || !this.IsPlaying) return; // 残機 1 減らす。火花生成。効果音。 player.IsDead = true; this.Life--; this.SetSparks(player.CX, player.CY); this.SoundPlayerDead.currentTime = 0; this.SoundPlayerDead.play(); setTimeout(() => { if(this.Life > 0){ // 囲い込まれた領域の境界(内部ではなく!)が復活地点の候補となる const starts = []; // 復活地点の候補 for(let v of this.VerticesMap.values()){ v.IsVisited = false; v.Nexts = v.Nexts.filter(next => this.GetColor(v, next) == 'white'); if(v.IsCovered()){ v.Nexts = v.Nexts.filter(next => next.IsCovered()); if(!v.IsCoveredCompletely()) starts.push(v); } } if(starts.length == 0){ // 復活地点の候補がない場合は中央で復活 player.Init(); player.Direct = 'L'; player.NextDirect = 'L'; } else { // 境界線上で復活させるが初期移動方向は移動可能方向でなければならない const start = starts[Math.floor(Math.random() * starts.length)]; const dir_set = new Set(); dir_set.add('U'); dir_set.add('D'); dir_set.add('L'); dir_set.add('R'); if(start.IsCoverdNE && start.IsCoverdNW) dir_set.delete('U'); if(start.IsCoverdSE && start.IsCoverdSW) dir_set.delete('D'); if(start.IsCoverdNW && start.IsCoverdSW) dir_set.delete('L'); if(start.IsCoverdNE && start.IsCoverdSE) dir_set.delete('R'); if(player.CX <= PLAYER_MIN) dir_set.delete('L'); if(player.CX >= PLAYER_MAX) dir_set.delete('R'); if(player.CY <= PLAYER_MIN) dir_set.delete('U'); if(player.CY >= PLAYER_MAX) dir_set.delete('D'); const dirs = []; for(let dir of dir_set.keys()) dirs.push(dir); const idx = Math.floor(Math.random() * dirs.length); player.Init(start.X, start.Y); player.Direct = dirs[idx]; player.NextDirect = dirs[idx]; } this.Enemies = []; } else if (this.IsPlaying) { // 残機 0 の場合はゲームオーバー this.IsPlaying = false; this.BGM.pause(); this.SoundGameover.play(); const player_name = document.getElementById('player-name').value; this.SaveScore(player_name, this.Score); // スコアランキングへの登録(後述) setTimeout(() => { document.getElementById('start-buttons').style.display = 'block'; }, 3000); } }, 2000); } } |
更新処理
更新処理を示します。前回の更新から 16 ms 以上経過している場合のみ更新処理をおこないます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Game { Update(){ requestAnimationFrame(() => this.Update()); const cur = Date.now(); const diff = cur - this.PrevUpdateTime; if(diff >= 16) this.PrevUpdateTime = cur; if(diff >= 16 && this.IsPlaying && !this.StopingUpdate){ this.UpdateCount++; this.MovePlayer(); this.MoveEnemies(); this.MoveSparks(); this.HitJudge(); this.CheckStageClear(); } this.Draw(); if(this.BGM.currentTime >= 45) // BGMを違和感なくエンドレスに再生 this.BGM.currentTime = 0; } } |
描画処理
自機、敵、境界線、自機の軌跡を描画する処理を示します。
|
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 |
class Game { Draw(){ // 全体を黒で塗りつぶす ctx.fillStyle = '#000'; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); for(let v of this.VerticesMap.values()){ // 境界線、自機の軌跡 v.Nexts.forEach(next => { ctx.beginPath(); ctx.moveTo(v.X, v.Y); ctx.lineTo(next.X, next.Y); ctx.closePath(); ctx.strokeStyle = this.GetColor(v, next) == 'white' ? '#fff' : '#f00'; ctx.lineWidth = 2; ctx.stroke(); }); // 領域の内部を青で塗りつぶす if(v.IsCoverdSE){ ctx.fillStyle = '#00f'; ctx.fillRect(v.X, v.Y, CELL_SIZE, CELL_SIZE); } } // 自機、敵、火花 this.Player.Draw(); this.Enemies.forEach(enemy => enemy.Draw()); this.Sparks.forEach(spark => spark.Draw()); // スコア、残機数 this.$score.innerHTML = `Score <span class = "ml-10">${this.Score.toLocaleString()}</span>`; this.$life.innerHTML = `${this.Rate} % <span class = "ml-20">Life ${this.Life}</span>`; } } |
スコアランキングへの登録
スコアランキングに登録する処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Game { SaveScore(player_name, score){ if(player_name == '') player_name = '名無しのゴンベ'; // JSON形式でPOST fetch('./ranking.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: player_name, score: score, }) }); } } |
ページが読み込まれたときの処理
ページが読み込まれたときの処理を示します。
|
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 |
window.addEventListener('load', () => { if(!isMobile()){ document.getElementById('control-buttons').style.display = 'none'; } function isMobile(){ const sw = window.screen.width; return sw <= 800; } const $playerName = document.getElementById('player-name'); const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName && $playerName != null) $playerName.value = savedName; $playerName?.addEventListener('change', () => { localStorage.setItem('hatodemowakaru-player-name', $playerName.value ); }); const game = new Game(); document.getElementById('start').addEventListener('click', () => game.GameStart()); document.addEventListener('keydown', (ev) => { if(game.IsPlaying) ev.preventDefault(); if(ev.key == 'ArrowLeft') game.SetPlayerDirct('L'); if(ev.key == 'ArrowRight') game.SetPlayerDirct('R'); if(ev.key == 'ArrowUp') game.SetPlayerDirct('U'); if(ev.key == 'ArrowDown') game.SetPlayerDirct('D'); }); document.addEventListener('keyup', (ev) => { return; if(ev.key == 'ArrowLeft') game.SetPlayerDirct('N'); if(ev.key == 'ArrowRight') game.SetPlayerDirct('N'); if(ev.key == 'ArrowUp') game.SetPlayerDirct('N'); if(ev.key == 'ArrowDown') game.SetPlayerDirct('N'); }); const $up = document.getElementById('up'); const $down = document.getElementById('down'); const $left = document.getElementById('left'); const $right = document.getElementById('right'); $up.addEventListener('mousedown', () => game.SetPlayerDirct('U')); $up.addEventListener('touchstart', () => game.SetPlayerDirct('U')); $down.addEventListener('mousedown', () => game.SetPlayerDirct('D')); $down.addEventListener('touchstart', () => game.SetPlayerDirct('D')); $left.addEventListener('mousedown', () => game.SetPlayerDirct('L')); $left.addEventListener('touchstart', () => game.SetPlayerDirct('L')); $right.addEventListener('mousedown', () => game.SetPlayerDirct('R')); $right.addEventListener('touchstart', () => game.SetPlayerDirct('R')); game.Update(); initVolume('volume', game.Sounds); }); |
以下はレンジスライダーでボリュームコントロールができるようにするための定番の処理です。
|
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 initVolume(elementId, sounds){ let volume = 0.3; const savedVolume = localStorage.getItem('hatodemowakaru-volume'); if(savedVolume) volume = Number(savedVolume); const $element = document.getElementById(elementId); const $div = document.createElement('div'); const $span1 = document.createElement('span'); $span1.innerHTML = '音量'; $div.appendChild($span1); const $range = document.createElement('input'); $range.type = 'range'; $div.appendChild($range); const $span2 = document.createElement('span'); $div.appendChild($span2); $range.addEventListener('input', () => { const value = $range.value; $span2.innerText = value; volume = Number(value) / 100; setVolume(); }); $range.addEventListener('change', () => localStorage.setItem('hatodemowakaru-volume', volume.toString())); setVolume(); $span2.innerText = Math.round(volume * 100).toString(); $span2.style.marginLeft = '16px'; $range.value = Math.round(volume * 100).toString(); $range.style.width = '230px'; $range.style.verticalAlign = 'middle'; $element.appendChild($div); const $button = document.createElement('button'); $button.innerHTML = '音量テスト'; $button.style.width = '120px'; $button.style.height = '45px'; $button.style.marginTop = '12px'; $button.style.marginLeft = '32px'; $button.addEventListener('click', () => { sounds[0].currentTime = 0; sounds[0].play(); }); $element.appendChild($button); function setVolume(){ for(let i = 0; i < sounds.length; i++) sounds[i].volume = volume; } } |
