GAME

ブラウザ版ルートウォーデンの実装を解説

固定ルートの作成、砲台と敵の管理、ウェーブ進行、Canvas描画、ゲームループまで、タワーディフェンスゲームの実装を順番に紹介します。

ルートウォーデンは、敵が決められたルートを進んで拠点を目指すタワーディフェンスゲームです。プレイヤーは建設ポイントへ砲台を設置し、クレジットで通常強化を行い、敵が落とす強化コアでさらに性能を高めます。

ゲーム本体は、PHPで共通ヘッダーなどを読み込み、CanvasとJavaScriptで戦場を描画・更新しています。固定ルート型にすることで、経路探索を使わずに敵の移動を分かりやすく実装できます。この記事では、ゲーム内のデータがどのように変化し、画面へ反映されるかを見ていきます。

マップをルートと建設ポイントで表す

マップは、敵が通る折れ線の座標と、砲台を置ける建設ポイントの座標に分けて定義しています。渓谷、砂漠、夜間の3マップがあり、それぞれルート、拠点、背景色、建設ポイントを持ちます。

マップデータを定義するJAVASCRIPT
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種類の座標と拠点位置を読み込み直すため、敵の移動処理や砲台の処理は共通のまま使えます。

ゲームの状態をオブジェクトで管理する

画面に表示するクレジットや拠点耐久値だけでなく、敵、弾、演出用パーティクル、ドロップアイテムも配列で管理しています。砲台は建設ポイントの中に保存し、選択中のポイント番号を別の変数で持ちます。

ゲーム全体の状態JAVASCRIPT
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を設定します。状態を一つにまとめることで、ウェーブ中だけ敵を出現させる、終了後は砲台を操作させない、といった条件を各処理で共有できます。

砲台の種類と強化値を設定から作る

連射砲、重砲、減速砲の違いは、種類ごとの設定オブジェクトへ切り出しています。価格、射程、攻撃力、攻撃間隔などを設定にまとめると、設置処理を砲台ごとに複製せずに済みます。

砲台の基本性能JAVASCRIPT
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フレームに進める距離が区間の残りより大きければ、次の区間へ進んで余った距離も続けて処理します。

敵を次の地点へ進めるJAVASCRIPT
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の倍数のウェーブに現れるボスをランダムに混ぜています。

敵の性能をウェーブから作るJAVASCRIPT
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へ追加し、弾の移動と命中を別の処理に分けています。これにより、弾が飛ぶ見た目を描画できます。

射程内の敵へ弾を発射するJAVASCRIPT
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では、背景、ルート、拠点、建設ポイント、砲台、敵、弾、パーティクルを順に描画します。ルートは太い暗色の線を引いたあと、細い破線を重ねて、敵が進む道として見えるようにしています。

ルートを描画するJAVASCRIPT
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でゲームループを作る

ゲームループでは前のフレームからの経過時間を求め、敵、砲台、弾、演出、ウェーブのタイマーを更新してから描画します。フレーム数ではなく経過時間を使うことで、画面の更新頻度が違ってもゲーム速度が大きく変わりません。

更新と描画を繰り返すJAVASCRIPT
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つを追うと、ゲーム全体の流れをつかみやすくなります。