ブラウザ版ルートウォーデンの実装を解説
固定ルートの作成、砲台と敵の管理、ウェーブ進行、Canvas描画、ゲームループまで、タワーディフェンスゲームの実装を順番に紹介します。
ルートウォーデンは、敵が決められたルートを進んで拠点を目指すタワーディフェンスゲームです。プレイヤーは建設ポイントへ砲台を設置し、クレジットで通常強化を行い、敵が落とす強化コアでさらに性能を高めます。
ゲーム本体は、PHPで共通ヘッダーなどを読み込み、CanvasとJavaScriptで戦場を描画・更新しています。固定ルート型にすることで、経路探索を使わずに敵の移動を分かりやすく実装できます。この記事では、ゲーム内のデータがどのように変化し、画面へ反映されるかを見ていきます。
マップをルートと建設ポイントで表す
マップは、敵が通る折れ線の座標と、砲台を置ける建設ポイントの座標に分けて定義しています。渓谷、砂漠、夜間の3マップがあり、それぞれルート、拠点、背景色、建設ポイントを持ちます。
const MAPS = {
basin: {
name: '翠緑の渓谷',
colors: ['#173927', '#0d281d'],
base: [760, 560],
path: [[-25,90], [150,90], [150,240], [340,240],
[340,110], [530,110], [530,390], [680,390],
[680,560], [825,560]],
pads: [[82,158], [230,155], [250,305], [415,175]]
}
};pathは敵が順番に通過する折れ線で、画面の外側から始まり拠点の近くで終わります。padsは砲台の設置位置です。マップを切り替えると、この2種類の座標と拠点位置を読み込み直すため、敵の移動処理や砲台の処理は共通のまま使えます。
ゲームの状態をオブジェクトで管理する
画面に表示するクレジットや拠点耐久値だけでなく、敵、弾、演出用パーティクル、ドロップアイテムも配列で管理しています。砲台は建設ポイントの中に保存し、選択中のポイント番号を別の変数で持ちます。
let money = 120;
let baseHp = 100;
let cores = 0;
let wave = 0;
let enemies = [];
let shots = [];
let particles = [];
let drops = [];
let selected = -1;
let state = 'ready';stateには準備中のready、ウェーブ中のwave、インターバル中のinterval、終了後のoverを設定します。状態を一つにまとめることで、ウェーブ中だけ敵を出現させる、終了後は砲台を操作させない、といった条件を各処理で共有できます。
砲台の種類と強化値を設定から作る
連射砲、重砲、減速砲の違いは、種類ごとの設定オブジェクトへ切り出しています。価格、射程、攻撃力、攻撃間隔などを設定にまとめると、設置処理を砲台ごとに複製せずに済みます。
const TYPES = {
rapid: { name: '連射砲', cost: 60, range: 112, damage: 8, rate: .34 },
cannon: { name: '重砲', cost: 95, range: 135, damage: 28, rate: 1.15, splash: 38 },
frost: { name: '減速砲', cost: 80, range: 105, damage: 5, rate: .75, slow: .52 }
};
function towerStats(t) {
const base = TYPES[t.type];
const mult = 1 + (t.level - 1) * .32 + t.core * .22;
return {
range: base.range + (t.level - 1) * 7 + t.core * 5,
damage: base.damage * mult,
rate: Math.max(.14, base.rate * (1 - (t.level - 1) * .08 - t.core * .06))
};
}砲台はtype、level、core、次の攻撃までのcoolなどを持ちます。towerStatsは基本性能と強化段階から現在の性能を計算する関数です。状態に計算結果を重複して保存しないため、強化後の表示と実際の攻撃性能がずれにくくなります。
敵をルートの線分に沿って移動させる
敵には現在の座標と、ルート上の何番目の区間にいるかを保存しています。次の座標までの距離を求め、1フレームに進める距離が区間の残りより大きければ、次の区間へ進んで余った距離も続けて処理します。
const next = path[enemy.segment + 1];
if (!next) {
baseHp -= enemy.damage;
enemy.dead = true;
} else {
const dx = next[0] - enemy.x;
const dy = next[1] - enemy.y;
const distance = Math.hypot(dx, dy);
if (move >= distance) {
enemy.x = next[0];
enemy.y = next[1];
enemy.segment++;
move -= distance;
} else {
enemy.x += dx / distance * move;
enemy.y += dy / distance * move;
move = 0;
}
}この方式では、敵が曲がり角で止まったり、フレームレートによってルート上の位置がずれたりする問題を抑えられます。減速砲の効果は敵が持つslowを更新し、移動距離へ倍率を掛けることで表現しています。
ウェーブと敵の種類を組み立てる
ウェーブ開始時に敵の残り数を設定し、ゲームループの中で一定間隔ごとに出現させます。ウェーブが進むほど体力と速度が上がり、装甲型、俊足型、5の倍数のウェーブに現れるボスをランダムに混ぜています。
function enemyConfig() {
const boss = wave % 5 === 0 && spawnLeft === 1;
const armored = wave >= 4 && Math.random() < .25;
const swift = !armored && !boss && wave >= 3 && Math.random() < .22;
let hp = 34 + wave * 13;
let speed = 44 + wave * 1.7;
let reward = 10 + Math.floor(wave * .8);
if (armored) { hp *= 2.1; speed *= .72; }
if (swift) { hp *= .68; speed *= 1.55; }
if (boss) { hp *= 7; speed *= .62; reward += 55; }
return { hp, maxHp: hp, speed, reward, segment: 0, slow: 0 };
}敵の種類を別々のクラスにせず、基本値へ補正を加えて一つの敵オブジェクトを作っています。種類ごとの見た目や報酬も同じオブジェクトへ追加できるため、敵のバリエーションを増やしやすい構成です。
射程、弾、範囲攻撃を分けて処理する
砲台は射程内の敵を探し、攻撃間隔が0になったら弾を発射します。砲台自身が直接敵の体力を減らすのではなく、弾をshotsへ追加し、弾の移動と命中を別の処理に分けています。これにより、弾が飛ぶ見た目を描画できます。
function fireTower(pad, tower, target) {
const stats = towerStats(tower);
const angle = Math.atan2(target.y - pad.y, target.x - pad.x);
tower.angle = angle;
tower.cool = stats.rate;
shots.push({
x: pad.x + Math.cos(angle) * 17,
y: pad.y + Math.sin(angle) * 17,
target,
damage: stats.damage,
splash: stats.splash || 0,
slow: stats.slow || 0,
dead: false
});
}重砲は命中地点の周囲にいる敵へ追加ダメージを与え、減速砲は命中した敵のslowを更新します。砲台の性能計算、弾の移動、命中後の効果を分けることで、射程や弾速などの調整もしやすくなります。
Canvasを状態から毎フレーム描画する
drawでは、背景、ルート、拠点、建設ポイント、砲台、敵、弾、パーティクルを順に描画します。ルートは太い暗色の線を引いたあと、細い破線を重ねて、敵が進む道として見えるようにしています。
function drawPath() {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#314434';
ctx.lineWidth = 52;
ctx.beginPath();
path.forEach((point, index) => {
index ? ctx.lineTo(...point) : ctx.moveTo(...point);
});
ctx.stroke();
ctx.strokeStyle = '#d5ad54';
ctx.lineWidth = 4;
ctx.setLineDash([14, 13]);
ctx.stroke();
ctx.setLineDash([]);
}ゲームの正しい状態は配列やオブジェクトにあり、Canvasはそれを表示する役割に限定しています。建設ポイントが選択されているときだけ砲台の射程円を描くため、プレイヤーは設置位置と攻撃範囲を確認しながら配置できます。
requestAnimationFrameでゲームループを作る
ゲームループでは前のフレームからの経過時間を求め、敵、砲台、弾、演出、ウェーブのタイマーを更新してから描画します。フレーム数ではなく経過時間を使うことで、画面の更新頻度が違ってもゲーム速度が大きく変わりません。
function loop(now) {
if (!running) return;
const dt = Math.min(.05, (now - last) / 1000);
last = now;
update(dt);
draw();
raf = requestAnimationFrame(loop);
}
function startGame() {
initAudio();
reset();
running = true;
last = performance.now();
raf = requestAnimationFrame(loop);
}dtに上限を設けているのは、タブを切り替えたあとに巨大な経過時間が一度に流れ、敵が拠点まで瞬間移動するのを防ぐためです。ゲームオーバーや全ウェーブクリアではrunningをfalseにしてアニメーションフレームを停止します。
クリック操作と音声をブラウザに合わせる
Canvasのクリック位置は、表示サイズと内部の800×650座標の比率を使って変換しています。建設ポイントの近くをクリックしたときだけ選択状態を更新し、砲台の種類はサイドパネルのボタンから設置します。Canvasにはpointerdownを使っているため、マウスとタッチの操作を同じ処理で扱えます。
効果音とBGMはWeb Audio APIでリアルタイム生成しています。ブラウザの自動再生制限があるため、AudioContextの初期化は「防衛を開始」や音声ボタンなどのユーザー操作の中で行います。BGMと効果音を別々にミュートできるよう、フラグも分けて管理しています。
実装して分かったこと
ルートウォーデンでは、タワーディフェンスの面白さを作る処理と、画面へ表示する処理を分離することが重要です。マップを座標データとして持ち、敵の移動を区間ごとに処理し、砲台・敵・弾をそれぞれの配列で更新することで、固定ルートでも多くの要素を組み合わせられます。
ゲームを遊ぶときは、実際のルートウォーデンのページで3種類のマップと砲台を試せます。コードを読むときは、まずloadMap、towerStats、startWave、update、drawの5つを追うと、ゲーム全体の流れをつかみやすくなります。