JavaScriptでスプラトゥーン風の陣取りシューティングをゼロから作ろう!

JavaScriptでスプラトゥーン風の陣取りシューティングをゼロから作ろう!

HTML・CSS・JavaScriptだけで、ブラウザで動くスプラトゥーン風のナワバリバトル「インクバトル!」を完成させるチュートリアルです。
床をインクで塗りまくる陣取り、イカになって高速移動、3 vs 3 でコンピューターと戦うAI対戦まで、全部自分の手で作って本格的なゲームに仕上げよう。

⏱ 読了:約35分 🎯 対象:プログラミング初級者〜中級者

📋 もくじ

  1. 完成するとどうなる?
  2. 準備するもの
  3. ファイル構成を作ろう
  4. HTMLを書こう
  5. CSSでデザインしよう
  6. JavaScriptの設計を決めよう
  7. ステージと塗りマスを用意しよう
  8. インクを塗るしくみ
  9. キャラクターを描こう
  10. 移動のしくみ:インクの上で速さが変わる
  11. インクショットを撃とう
  12. キーとマウスの入力
  13. AIを動かそう
  14. 当たり判定・やられ・復活
  15. パーティクルと効果音
  16. 塗り率の計算とHUD
  17. メインループで全部つなげる
  18. 完成コード全文
  19. 動かしてみよう
  20. 次のステップ

完成するとどうなる?

この記事を最後まで読むと、ブラウザ上で遊べるスプラトゥーン風のナワバリバトルが完成します。ただ撃ち合うだけじゃなくて、こんな機能がついています:

  • 床をインクで塗り、塗った面積(陣地)の広さで勝敗が決まる陣取りシステム
  • プレイヤー1人+AI5体(3 vs 3)が同時に動くチーム戦
  • Shiftキーでイカ状態に変身。自分のインクの上をスイスイ高速移動&インク補給
  • 自分のインクの上は速く、敵のインクの上はぬかるんで遅くなるスピード変化
  • マウスでねらってクリックでインクショットを連射、当てると相手をやられ状態にできる
  • やられても数秒で復活、復活直後は無敵時間で守られる
  • インクがはじけるパーティクル演出と、その場で音を合成する効果音
  • 塗り率バー・タイマー・インク残量ゲージのHUD、勝敗を出すリザルト画面
  • BGMのループ再生

使うのはHTML・CSS・JavaScriptの3つだけ。キャラも弾もインクのしぶきも、画像ファイルは一切なしでぜんぶJavaScriptで描きます

テトリスやアンダーテイル風バトルでゲームループに慣れた人はもちろん、「たくさんのキャラを同時に動かす」「コンピューターに考えさせる(AI)」「面積を数えて勝敗を決める」という新しいテーマを学べるので、Canvasゲーム3作目・4作目の題材にぴったりです。

準備するもの

以下の2つだけ用意してください。

  • テキストエディタ(VS CodeやAntigravityがおすすめ)
  • Webブラウザ(Chrome、Edge、Safariなど何でもOK)

BGMを鳴らしたい人は、好きなmp3ファイルを1つ用意しておきましょう(フリーBGMサイトからダウンロードしてもOK。なくてもゲームは動きます)。

ファイル構成を作ろう

パソコンのどこかに ink-battle という名前のフォルダを作って、その中にファイルを用意します。

ink-battle/
    index.html ─ 画面の骨組み(HUD・スタート/リザルト画面)
    style.css  ─ 見た目のデザイン
    script.js  ─ ゲーム本体(全部の動き)
    bgm.mp3    ─ BGM(お好みで)

アンダーテイル風バトルやレーシングのときと同じ「HTML・CSS・JS の3点セット」です。どんなゲームを作るときもこの構成は変わりません。

HTMLを書こう

今回はアンダーテイル編と少しちがって、HUD(体力バーやタイマーなどの情報表示)や画面切り替えをHTMLの要素で作ります。index.html に以下を書きましょう。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>インクバトル! - スプラトゥーン風ゲーム</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div id="game-container">
      <!-- HUD:プレイ中ずっと表示される情報 -->
      <div id="hud">
        <div id="score-bar">
          <div id="score-orange"></div>
          <div id="score-purple"></div>
        </div>
        <div id="timer">1:30</div>
        <div id="ink-gauge-wrap">
          <div class="gauge-label">インク</div>
          <div id="ink-gauge"><div id="ink-fill"></div></div>
        </div>
      </div>

      <!-- ゲーム画面そのもの。ここに script.js が絵を描く -->
      <canvas id="game" width="960" height="600"></canvas>

      <!-- やられている間だけ出るメッセージ -->
      <div id="respawn-msg" class="hidden">
        やられた! 復活まで <span id="respawn-count">3</span>
      </div>

      <!-- スタート画面(省略。完成コードを参照) -->
      <div id="start-screen" class="overlay"> ... </div>
      <!-- リザルト画面(省略。完成コードを参照) -->
      <div id="result-screen" class="overlay hidden"> ... </div>
    </div>

    <audio id="bgm" src="bgm.mp3" loop preload="auto"></audio>
    <script src="script.js"></script>
  </body>
</html>

HTMLのポイント解説

主役は <canvas id="game">960×600ピクセルの画用紙に、JavaScriptがキャラも弾もインクも毎フレーム描き直していきます。

アンダーテイル編ではHPバーやセリフまで全部Canvasに描きましたが、今回は塗り率バー・タイマー・インクゲージ・スタート画面・リザルト画面はHTML要素で作り、キャンバスの上に重ねます。ボタンを押したりCSSアニメでバーをスーッと伸ばしたりする部分は、HTML+CSSに任せたほうがラクだからです。「画面に情報を出す」方法は、作るゲームやパーツによってベストが変わる、というのが今回いちばん覚えてほしいポイント。

キャンバスの width="960" height="600" は、あとで script.js に出てくる STAGE_WIDTH / STAGE_HEIGHT必ず同じ数字にそろえてください。ここがズレると、マウスのねらいや塗り判定が全部ずれます。

<audio id="bgm"> はBGM用。loop を付けると曲が終わっても自動で最初に戻ります。

CSSでデザインしよう

style.css では、まず色に名前を付けておきます。あとで色を変えたいとき、1か所直すだけで全部に反映されます。

:root {
  --orange: #ff6a00;
  --orange-light: #ffb347;
  --orange-dark: #b34a00;
  --purple: #8a2be2;
  --purple-light: #a55eea;
  --accent: #ffd400;   /* 強調したい文字の黄色 */
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* flex の3行で、ゲーム画面を画面のど真ん中に置く */
body {
  background: #1a1a24;
  color: #fff;
  font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  overflow: hidden;
}

/* HUD やオーバーレイの位置の基準になる箱 */
#game-container {
  position: relative;
  width: 960px;
}

CSSのポイント解説

:root に書いた --orange などはCSS変数。使うときは var(--orange) と書きます。「色は一箇所にまとめる」のは、アンダーテイル編で定数をまとめたのと同じ発想です。

#game-containerposition: relative は超重要。この指定があると、中に入れたHUDやスタート画面(position: absolute)が「この箱の左上を基準」に置かれます。だから top: 10px と書けばキャンバスの上10pxにピタッと重なります。

もうひとつ知っておきたいのが、画面の出し入れに使う .hidden クラス。

.hidden {
  display: none !important;
}

script.js はこの hidden付けたり外したりするだけで、スタート画面やリザルト画面を出したり消したりします。!important は「他のどんな指定にも必ず勝つ」ためのおまじない。

※ CSS全文は約300行あります。塗り率バーのアニメーションやボタンの押し込み表現なども入っていますが、長いので完成コードにまとめてあります。

JavaScriptの設計を決めよう

ここからが本番です! script.js は全体で1200行ほど(うち半分は初心者向けの解説コメント)。書き始める前に設計を決めましょう。

このゲームは、大きく分けてこんな部品でできています。

① ステージ  … 床・障害物・「どのマスを誰が塗ったか」の記録
② インク    … 塗る(見た目づくり+陣地の記録)
③ キャラ    … プレイヤーもAIも同じ設計図(クラス)から作る
④ 弾        … 飛ばして・塗って・当てる
⑤ 入力      … キーボードとマウス
⑥ 進行      … 時間・勝敗・HUD・リザルト
⑦ メインループ … 毎フレーム、上の全部を動かして描く

まずは「設定」から。ゲームの遊び心地を決める数字を、定数として最初にまとめておきます。アンダーテイル編の CONFIG と同じ発想で、あとから数字を1つ変えるだけで遊び心地がガラッと変わります。

"use strict";

// ---------- ステージの大きさ ----------
const STAGE_WIDTH = 960;   // ※ index.html の canvas と同じ値にすること
const STAGE_HEIGHT = 600;

// ---------- 塗り判定用のマス目 ----------
const CELL_SIZE = 8;                          // 8px 四方を1マスとする
const GRID_COLS = STAGE_WIDTH / CELL_SIZE;    // 横のマス数 (120)
const GRID_ROWS = STAGE_HEIGHT / CELL_SIZE;   // 縦のマス数 (75)

const GAME_TIME_SEC = 90;   // 試合時間(秒)
const HURRY_UP_SEC = 10;    // 残りこの秒数でタイマーが点滅

// ---------- チーム ----------
const TEAM_ORANGE = 0;
const TEAM_PURPLE = 1;
const UNPAINTED = -1;  // まだ誰も塗っていないマス
const WALL = -2;       // 障害物なので塗れないマス

const enemyOf = (team) => 1 - team;  // 相手チームの番号(0↔1)

💡 数字と「意味のある名前」を先に決めておく

TEAM_ORANGE = 0 のように、0や1という数字に名前を付けておくと、あとのコードが if (team === TEAM_ORANGE) のように英語として読めます。塗られていないマスを -1、壁を -2 という特別な値にしておくのもテクニック。これで「1つの配列に、チームの色も・未塗装も・壁も、ぜんぶ数字1個で記録できる」ようになります。

キャラの性能・移動スピード・ブキ・AIの賢さも、同じように定数でまとめておきます。

const CHARACTER = {
  MAX_HP: 100, MAX_INK: 100,
  HIT_RADIUS: 18,       // 弾が当たったと判定する半径(見た目より少し大きめ)
  RESPAWN_SEC: 2.5,     // やられてから復活するまで
  INVINCIBLE_SEC: 2,    // 復活直後の無敵時間
};

// 移動スピード(px/秒)。ここがこのゲームの肝!
const SPEED = {
  HUMAN_OWN_INK: 215,    // ヒト × 自分のインク
  HUMAN_ENEMY_INK: 100,  // ヒト × 敵のインク(ぬかるんで遅い)
  HUMAN_NEUTRAL: 190,    // ヒト × 素の床
  SQUID_OWN_INK: 350,    // イカ × 自分のインク(超スピード!)
  SQUID_ELSEWHERE: 90,   // イカ × それ以外(もぐれずノロノロ)
};

const WEAPON = {
  FIRE_INTERVAL: 0.09,  // 連射の間隔(小さいほど速い)
  INK_COST: 2.5,        // 1発あたりのインク消費
  DAMAGE: 28,           // 1発のダメージ(約4発で倒せる)
  SPREAD: 0.1,          // 弾のブレ幅(ラジアン)
};

SPEED の5つの数字を見比べるだけで、このゲームのルールが分かります。「自分の色の上は速く、敵の色の上は遅い」——だから相手の陣地を塗りつぶすと、攻めながら自分は動きやすく、相手は動きにくくなる。塗ることが攻めにも守りにもなる、というスプラトゥーンのおもしろさの正体は、この数字の差なんです。

ステージと塗りマスを用意しよう

ステージには障害物(壁)と、塗った色を記録するマス目があります。

障害物は「x, y(左上の座標)と w, h(幅と高さ)」を持つただの四角。配列に並べておきます。

const obstacles = [
  { x: 410, y: 240, w: 140, h: 120 },  // 中央ブロック
  { x: 170, y: 110, w: 130, h: 46 },   // 左上
  { x: 660, y: 444, w: 130, h: 46 },   // 右下
  { x: 700, y: 90, w: 46, h: 120 },    // 右上
  { x: 214, y: 390, w: 46, h: 120 },   // 左下
];

そして陣取りの心臓部、塗りマス(グリッド)です。

// 中身は UNPAINTED / WALL / TEAM_ORANGE / TEAM_PURPLE のどれか
const grid = new Int8Array(GRID_COLS * GRID_ROWS);

// マス目(gx, gy) が配列の何番目か
const gridIndex = (gx, gy) => gy * GRID_COLS + gx;

💡 「2次元のマス目」を1本の配列で持つ

ステージは横120×縦75のマス目ですが、grid1本の細長い配列(要素数 120×75=9000)です。2次元に見せるコツが gy * GRID_COLS + gx という式。「1行が120マスだから、y行めのxマス目は y×120 + x 番目」という計算です。この形は画像処理でもよく出てくる定番なので、覚えておくと得します。

Int8Array は「-128〜127の整数だけを入れる、軽くて速い配列」。色の番号(0か1)と未塗装(-1)・壁(-2)しか入れないので、これで十分です。

ゲーム開始時に、まず全マスを「未塗装」にして、障害物のあるマスだけ「壁」に書きかえます。

function resetGrid() {
  grid.fill(UNPAINTED);
  for (const o of obstacles) {
    const left = Math.floor(o.x / CELL_SIZE);
    const right = Math.ceil((o.x + o.w) / CELL_SIZE);
    const top = Math.floor(o.y / CELL_SIZE);
    const bottom = Math.ceil((o.y + o.h) / CELL_SIZE);
    for (let gy = top; gy < bottom; gy++) {
      for (let gx = left; gx < right; gx++) {
        if (isInsideGrid(gx, gy)) grid[gridIndex(gx, gy)] = WALL;
      }
    }
  }
}

ピクセル座標をマス目に直すときは、Math.floor(x / CELL_SIZE) で「何マス目か」を求めます。/ CELL_SIZE は「8で割る=8pxを1マスに縮める」、Math.floor は「はみ出した端数を切り捨てて整数マスにする」ということ。ピクセルの世界とマスの世界を行き来する変換は、このあと何度も出てきます。

インクを塗るしくみ

このゲームで一番おもしろい工夫が、インクの塗りを「2つに分けて」管理するところです。

見た目用  … inkCanvas(見えないキャンバス)に塗り絵をためる
計算用    … grid(マス目の配列)に「どのマスが誰の色か」を記録する

なぜ分けるのか? 見た目はキレイなしぶきにしたい、でも陣地の割合(%)はカッチリ数えたい。この2つは相性が悪いので、役割ごとに別のデータにするのです。

まず「見えないキャンバス」を用意します。

const inkCanvas = document.createElement("canvas");
inkCanvas.width = STAGE_WIDTH;
inkCanvas.height = STAGE_HEIGHT;
const inkCtx = inkCanvas.getContext("2d");

画面には出さず、塗った跡だけをここにためていきます。そして毎フレーム、この1枚を画面へペタッと丸ごとコピーします。毎回すべてのしぶきを描き直すと重すぎるので、「一度描いたものはためておいて使い回す」という工夫です。

塗る関数は、見た目と記録の両方を更新します。

function paint(x, y, r, team) {
  paintInkLook(x, y, r, team);   // 見た目(inkCanvas)
  paintInkGrid(x, y, r, team);   // 記録(grid)
}

💡 わざと歪ませて「手描きのインク感」を出す

真ん丸を描くと、いかにもプログラムっぽくてつまらない。そこでわざとつぶした楕円をランダムな角度で描き、まわりに小さな飛び散りを散らします。

function paintInkLook(x, y, r, team) {
  const color = TEAM_COLORS[team];
  // メインのしぶき:真円ではなく、つぶれた楕円をランダムな角度で
  inkCtx.fillStyle = chance(0.7) ? color.main : color.light;
  inkCtx.beginPath();
  inkCtx.ellipse(x, y, r * rand(0.75, 1.1), r * rand(0.75, 1.1),
                 rand(0, Math.PI), 0, Math.PI * 2);
  inkCtx.fill();

  // まわりに小さな飛び散りをいくつか
  const dropCount = 2 + randInt(3);
  for (let i = 0; i < dropCount; i++) {
    const angle = rand(0, Math.PI * 2);
    const distance = r * rand(0.9, 1.7);
    inkCtx.beginPath();
    inkCtx.arc(x + Math.cos(angle) * distance,
               y + Math.sin(angle) * distance,
               r * rand(0.12, 0.3), 0, Math.PI * 2);
    inkCtx.fill();
  }
}

rand(0.75, 1.1) のようにちょっとした乱数を混ぜるだけで、同じ塗りでも1回ごとに形が変わり、手描きのような自然さが出ます。「乱数はリアルさの調味料」と覚えておきましょう。

記録のほう(grid)は、中心のマスから半径ぶんを円形に塗りかえます。

function paintInkGrid(x, y, r, team) {
  const radiusInCells = Math.max(1, Math.round((r * 0.9) / CELL_SIZE));
  const centerX = Math.floor(x / CELL_SIZE);
  const centerY = Math.floor(y / CELL_SIZE);
  for (let gy = centerY - radiusInCells; gy <= centerY + radiusInCells; gy++) {
    for (let gx = centerX - radiusInCells; gx <= centerX + radiusInCells; gx++) {
      if (!isInsideGrid(gx, gy)) continue;
      // 四角ではなく円の形に塗るため、遠すぎるマスは飛ばす
      const inCircle = (gx-centerX)**2 + (gy-centerY)**2 <= radiusInCells**2;
      if (!inCircle) continue;
      const idx = gridIndex(gx, gy);
      if (grid[idx] !== WALL) grid[idx] = team;  // 壁の上には塗れない
    }
  }
}

(gx-centerX)**2 + (gy-centerY)**2 <= radiusInCells**2 は、また出てきた三平方の定理。「中心からの距離が半径以内のマスだけ塗る」ことで、四角い二重ループなのに円形に塗れます。壁のマス(WALL)は上書きしないので、障害物の上は塗れません。

キャラクターを描こう

プレイヤーもAIも、まったく同じ設計図(クラス)から作ります。ちがいは「操作するのが人間かコンピューターか」だけ。

class Character {
  constructor(team, spawnX, spawnY, isPlayer, name) {
    this.team = team;
    this.spawnX = spawnX;   // 復活する場所
    this.spawnY = spawnY;
    this.isPlayer = isPlayer;
    this.name = name;
    this.kills = 0;
    this.deaths = 0;
    this.respawn();  // hp や座標の初期化はまとめてここで
  }
  // ...
}

💡 クラスは「キャラの設計図」

class Characterたい焼きの型のようなもの。1つ型を作れば、new Character(...) で何体でも同じ性能のキャラを量産できます。プレイヤー1体とAI5体、合計6体がこの型から生まれます。this.teamthis.hp はキャラ1体ずつが持つ「自分専用の数字」です。

描画は「今イカか、ヒトか」で切り替えます。

draw() {
  if (this.dead) return;
  // 無敵中は 0.1 秒ごとに点滅させる(描かないフレームを作る)
  const blinking = this.invincible > 0 && Math.floor(this.invincible * 10) % 2 === 0;
  if (blinking) return;

  ctx.save();
  ctx.translate(this.x, this.y);  // 原点を自分の位置に移す
  if (this.squid) this.drawSquidBody();
  else this.drawHumanBody();
  ctx.restore();

  this.drawNameTag();
  this.drawHpBar();
}

ctx.translate(this.x, this.y)原点をキャラの位置に移すと、体は「中心(0,0)まわりの座標」で1回書けばOK。位置が変わっても数字を書き直す必要がありません。save()restore() で挟むのはアンダーテイル編と同じ鉄則です。

点滅は Math.floor(this.invincible * 10) % 2 === 0 の1行。無敵時間を10倍して整数にし、偶数・奇数で「描く/描かない」を切り替えると、チカチカ点滅になります。タイマー1つで点滅、いろんなゲームで使える小技です。

ヒト形態は「影 → ブキ → 体 → ハチマキ → 目」の順に重ねて描きます。とくに目は、ねらっている方向へ少しずらすのがミソ。

// 目。向いている方向へ少しずらすと、そちらを見ているように見える
const eyeX = Math.cos(this.aimAngle) * 4;
const eyeY = Math.sin(this.aimAngle) * 4;

Math.cosMath.sin に角度を入れると、その向きの座標が出ます。目玉をほんの4pxずらすだけで、キャラがマウスのほうをキョロッと見るように見える。小さな工夫が「生きてる感」を生みます。

イカ形態は、quadraticCurveTo(2次ベジェ曲線)4本で描く小さな涙型。自分のインクの上では半透明にして「もぐっている」感じを出しています。

移動のしくみ:インクの上で速さが変わる

このゲーム最大の見せ場、「足元の色で速さが変わる」しくみです。まず今立っている地面の色を調べて、スピードを決めます。

groundSpeed() {
  const ground = gridAt(this.x, this.y);  // 足元のマスの色
  if (this.squid) {
    return ground === this.team ? SPEED.SQUID_OWN_INK : SPEED.SQUID_ELSEWHERE;
  }
  if (ground === this.team) return SPEED.HUMAN_OWN_INK;
  if (ground === enemyOf(this.team)) return SPEED.HUMAN_ENEMY_INK;
  return SPEED.HUMAN_NEUTRAL;
}

gridAt(x, y) は「そのピクセルの下のマスが何色か」を返す関数。足元が自分の色なら速く、敵の色なら遅く、イカ状態+自分の色なら爆速、というルールがこの数行に詰まっています。

実際に動かすのが tryMove。壁にぶつかったときの処理がポイントです。

tryMove(dx, dy, dt) {
  const len = Math.hypot(dx, dy);
  if (len === 0) return;
  // 長さ1の向きに直してから速度をかける(斜めだけ速くならないように)
  const speed = this.groundSpeed();
  const nextX = this.x + (dx / len) * speed * dt;
  const nextY = this.y + (dy / len) * speed * dt;

  // X と Y を別々に判定すると、壁に当たっても「壁ずり」できる
  if (!inObstacle(nextX, this.y, CHARACTER.WALL_MARGIN)) {
    this.x = clamp(nextX, CHARACTER.EDGE_MARGIN, STAGE_WIDTH - CHARACTER.EDGE_MARGIN);
  }
  if (!inObstacle(this.x, nextY, CHARACTER.WALL_MARGIN)) {
    this.y = clamp(nextY, CHARACTER.EDGE_MARGIN, STAGE_HEIGHT - CHARACTER.EDGE_MARGIN);
  }
}

💡 斜め移動を「長さ1」にそろえる

(dx / len) の割り算がポイント。W+Dで右上に動くとき、そのまま足すと斜めだけ√2倍(約1.4倍)速くなってしまいます。向きを「長さ1のベクトル」に直してから速度をかけると、どの方向も同じ速さになります。地味だけど、これをやらないと「斜め移動が最速」の変なゲームになります。

💡 XとYを別々に判定すると「壁ずり」できる

壁に斜めから当たったとき、XとYをまとめて判定すると完全に止まってしまいます。でも別々に判定すると、「壁に沿った方向にはスッと動ける」ようになる。アクションゲームで壁際がストレスなく動けるのは、この工夫のおかげです。

clamp(値, 最小, 最大) は値を範囲内に収める定番の書き方。キャラが画面の外に出られないのはこの関数のおかげです。

インクショットを撃とう

弾は { x, y, vx, vy, ... } というオブジェクトにして、projectiles 配列で管理します。撃つ関数はこうです。

function shoot(owner, aimX, aimY, spread) {
  const angle = Math.atan2(aimY - owner.y, aimX - owner.x) + rand(-spread, spread);
  const speed = rand(WEAPON.SPEED_MIN, WEAPON.SPEED_MAX);
  projectiles.push({
    x: owner.x + Math.cos(angle) * WEAPON.MUZZLE_OFFSET,  // 銃口の位置から出す
    y: owner.y + Math.sin(angle) * WEAPON.MUZZLE_OFFSET,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    team: owner.team,
    owner,                                       // 誰が撃ったか(キル記録用)
    range: rand(WEAPON.RANGE_MIN, WEAPON.RANGE_MAX),  // 飛べる距離
    traveled: 0, r: rand(4, 6), trailTimer: 0,
  });
  playSound("shoot");
}

Math.atan2(縦の差, 横の差) は「その方向を向く角度」を返す関数。狙った点への角度を出し、そこに rand(-spread, spread)小さなブレを足して、弾がちょっとだけ散るようにしています。ブレがあると、置きっぱなしで撃っても広く塗れて気持ちいい。

毎フレーム、飛んでいる弾を1つずつ動かして「5つのこと」を調べます。

function updateProjectiles(dt) {
  for (let i = projectiles.length - 1; i >= 0; i--) {  // 消しながら回すので後ろから
    const p = projectiles[i];
    p.x += p.vx * dt;  p.y += p.vy * dt;
    p.traveled += Math.hypot(p.vx, p.vy) * dt;

    // ① 飛んでる間もチョロチョロ塗る(インクの軌跡)
    // ② 画面の外に出たら消す
    // ③ 障害物にぶつかったら、はじけて消える
    // ④ 敵に当たったらダメージ+着弾スプラッシュ
    // ⑤ 飛べる距離を使い切ったら、その場に大きく着弾
  }
}

💡 配列を消しながら回すときは「後ろから」

for (let i = ...length - 1; i >= 0; i--)うしろから前へたどっているのに注目。前から回すと、途中で splice(削除)したときに次の要素を飛ばしてしまうバグが出ます。「消しながらループするときは後ろから」は超定番のルールです。

弾が飛びきったら、その場に大きく着弾させて広く塗ります。弾そのものが移動する塗りペンになっているわけです。

キーとマウスの入力

「押している間ずっと動く」を作るには、キーの状態を箱に覚えておくのが定番です。

const input = {
  up: false, down: false, left: false, right: false,
  shift: false, mouseDown: false,
  mouseX: STAGE_WIDTH / 2, mouseY: STAGE_HEIGHT / 2,
};

// キーのコード → input のどの項目か、の対応表
const KEYMAP = {
  KeyW: "up", ArrowUp: "up",   KeyS: "down", ArrowDown: "down",
  KeyA: "left", ArrowLeft: "left",  KeyD: "right", ArrowRight: "right",
};

window.addEventListener("keydown", (e) => {
  if (KEYMAP[e.code]) {
    input[KEYMAP[e.code]] = true;
    e.preventDefault();  // 矢印キーで画面がスクロールしないように
  }
  if (e.key === "Shift") input.shift = true;
});
window.addEventListener("keyup", (e) => {
  if (KEYMAP[e.code]) input[KEYMAP[e.code]] = false;
  if (e.key === "Shift") input.shift = false;
});

💡 対応表(KEYMAP)で if の山を消す

WASDと矢印キーの両方に対応させるとき、if を8個並べると読みにくい。「キーコード → 意味」の対応表を1つ作っておけば、input[KEYMAP[e.code]] = true の1行で全部さばけます。設定と処理を分けると、キーを増やすのも表に1行足すだけ。

マウスは、画面上の位置をキャンバス内の座標に変換するのがコツです。

canvas.addEventListener("mousemove", (e) => {
  const rect = canvas.getBoundingClientRect();
  input.mouseX = (e.clientX - rect.left) * (STAGE_WIDTH / rect.width);
  input.mouseY = (e.clientY - rect.top) * (STAGE_HEIGHT / rect.height);
});

キャンバスが縮小表示されていてもズレないよう、STAGE_WIDTH / rect.width(本来のサイズ÷表示サイズ)の比率をかけているのがポイント。これを忘れると、狙った場所とずれた所に弾が飛びます。

AIを動かそう

コンピューターが操作するキャラ(味方AI+敵AI)の「考え方」は、こんな3ステップです。

1. ときどき「まだ自分の色じゃないマス」を探して、そこを目的地にする
2. インクが減ったらイカになって補給し、たまったら戦線に戻る
3. 目的地へ歩きながら、近くに敵がいれば撃つ

目的地(waypoint)の決め方が賢いポイントです。

pickWaypoint() {
  for (let i = 0; i < AI.WAYPOINT_TRIES; i++) {  // ランダムに12回試す
    const gx = randInt(GRID_COLS);
    const gy = randInt(GRID_ROWS);
    const value = grid[gridIndex(gx, gy)];
    if (value === WALL || value === this.team) continue;  // 壁や塗り済みは用なし
    const px = gx * CELL_SIZE + CELL_SIZE / 2;
    const py = gy * CELL_SIZE + CELL_SIZE / 2;
    if (inObstacle(px, py, 16)) continue;
    this.wpX = px;  this.wpY = py;  // ここを次の目的地にする
    return;
  }
  // 見つからなければ適当な場所へ
  this.wpX = rand(40, STAGE_WIDTH - 40);
  this.wpY = rand(40, STAGE_HEIGHT - 40);
}

💡 「全部調べる」より「何回かサイコロを振る」

ステージ全体(9000マス)から「まだ塗ってない場所」を毎回さがすと重い。そこでランダムに12回だけ選んでみて、塗ってないマスが見つかればそこへ向かう、という手抜き(いい意味で!)をしています。結果として計算も軽く、AIの動きもバラけて自然になる。「完璧な最適解より、そこそこ良くて軽い方法」を選ぶのは、ゲームAIの大事な考え方です。

インク補給の切り替えにも工夫があります。

if (this.ink < AI.INK_START_RELOAD) this.reloading = true;  // 8以下で補給開始
if (this.ink > AI.INK_STOP_RELOAD) this.reloading = false;  // 45以上で復帰

入る境目(8)と出る境目(45)をわざとズラしているのに注目。もし同じ数字だと、境目でONとOFFがガタガタ切り替わってAIがフリーズしたように見えます。この「入口と出口をずらす」テクニックはヒステリシスと呼ばれ、エアコンの温度制御などにも使われる実用テクです。

敵をねらうときは、わざと AI.AIM_ERROR のぶんズラして「たまに外す」ようにしています。当てすぎるAIは強すぎて楽しくないからです。AIは強くするより「気持ちよく勝てる」くらいに調整するのがコツ。

当たり判定・やられ・復活

当たり判定は、弾とキャラを円とみなして距離で調べるだけ。

function findHitCharacter(p) {
  for (const ch of characters) {
    if (ch.team === p.team || ch.dead || ch.invincible > 0) continue;  // 味方・やられ中・無敵中は無視
    if (dist2(p.x, p.y, ch.x, ch.y) < CHARACTER.HIT_RADIUS ** 2) return ch;
  }
  return null;
}

💡 距離の比較は「2乗のまま」で速くする

dist2 は距離の2乗を返す関数です。本来の距離を出すには Math.sqrt(ルート)が必要ですが、ルートの計算は少し重い。「AとBどっちが近い?」を比べるだけなら、2乗同士を比べても答えは同じ。だからルートを取らずに 半径の2乗 と比べることで、判定を軽くしています。1フレームに何百回も走る処理なので、この節約が効きます。

やられたときは、倒した側の色でその場を大きく塗りつぶします

die(attacker) {
  this.dead = true;
  this.deaths++;
  this.respawnTimer = CHARACTER.RESPAWN_SEC;
  if (attacker) attacker.kills++;
  // やられた場所を、倒した側の色でドバッと塗る
  const inkTeam = attacker ? attacker.team : enemyOf(this.team);
  paint(this.x, this.y, 34, inkTeam);
  burstParticles(this.x, this.y, inkTeam, 24, 260);
  playSound("kill");
}

倒すこと自体が「大きく塗る」ことになるので、戦闘と陣取りがつながっています。復活の管理は update の中で行い、タイマーが0になったら respawn() でHPも位置も初期状態に戻します。

💡 無敵時間はゲームの優しさ

復活直後に INVINCIBLE_SEC ぶん無敵になり、その間は当たり判定そのものをスキップします。これがないと、復活した瞬間にまた撃たれて何もできずに連続やられになってしまう。ほとんどのアクションゲームに無敵時間があるのは、この理不尽を防ぐためです。

パーティクルと効果音

インクのつぶを飛ばす(パーティクル)

弾が当たったりやられたりしたとき、インクのつぶを放射状に飛ばします。1つ1つが小さな物理オブジェクトです。

function burstParticles(x, y, team, count, power) {
  for (let i = 0; i < count; i++) {
    const angle = rand(0, Math.PI * 2);
    const speed = rand(40, power);
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - rand(20, 80),  // 少し上に打ち上げる
      life: rand(0.3, 0.7), maxLife: 0.7, r: rand(2, 6), team,
    });
  }
}

毎フレーム、つぶに重力をかけて落とし、寿命が尽きたら消します。アンダーテイル編で出た「2行の物理エンジン(速度に重力を足す→位置に速度を足す)」がここでも活躍します。描くときは寿命が減るほど透明にして、フッと消えるようにします。

音をその場で作る(WebAudio)

効果音は、音ファイルを使わずにその場で波形を合成しています。

const SOUND_PRESETS = {
  shoot: { wave: "square",   startHz: [300, 380], slideTo: 120,  volume: 0.04, sec: 0.08 },
  splat: { wave: "triangle", startHz: [140, 200], slideTo: 60,   volume: 0.08, sec: 0.15 },
  kill:  { wave: "sawtooth", startHz: [500, 500], slideTo: 1200, volume: 0.08, sec: 0.25 },
};

wave(波の形)で音色が変わります。square=ピコピコ、triangle=やわらかい、sawtooth=ジャギッとした音。startHz から slideTo へ音程をスーッと変えると「ピュン」という発射音になります。ドレミの正体は「1秒間に何回振動するか(ヘルツ)」で、数字を変えるだけで音が作れる、というのは触ってみると感動します。

💡 音はユーザー操作のあとでしか鳴らせない

ブラウザは勝手に音を鳴らすことを禁止しているので、最初のクリックのタイミングでオーディオを起動します。

canvas.addEventListener("mousedown", () => {
  ensureAudio();  // 最初のクリックでオーディオを起動する
  input.mouseDown = true;
});

BGMのほう(bgm.mp3)は <audio> タグを操作するだけ。効果音にかき消されないよう、音量は少し控えめ(0.35)にしてあります。

塗り率の計算とHUD

勝敗を決める塗り率は、マス目を1つずつ数えるだけで求まります。

function computeCoverage() {
  let orange = 0, purple = 0, total = 0;
  for (let i = 0; i < grid.length; i++) {
    const v = grid[i];
    if (v === WALL) continue;  // 壁は「塗れる面積」に入れない
    total++;
    if (v === TEAM_ORANGE) orange++;
    else if (v === TEAM_PURPLE) purple++;
  }
  return { orange: (orange/total)*100, purple: (purple/total)*100 };
}

9000マスを全部数えるので、これは少し重い処理。だから毎フレームではなく0.5秒に1回だけ呼びます。

const COVERAGE_INTERVAL = 0.5;
// ...ループの中で...
coverageTimer -= dt;
if (coverageTimer <= 0) {
  coverageTimer = COVERAGE_INTERVAL;
  updateScoreBar();
}

💡 重い処理は「間引いて」呼ぶ

「毎フレームやる必要がないもの」は、タイマーを使って間引いて呼ぶと軽くなります。塗り率バーが0.5秒ごとにパッと更新されても、遊んでいて気になりません。「全部を毎フレーム完璧にやらない」のは、ゲームを軽くする王道テクです。

HUD(タイマーやインクゲージ)はHTML要素なので、JavaScriptから文字や幅を書きかえるだけ。

el("timer").textContent = `${min}:${String(sec).padStart(2, "0")}`;
el("ink-fill").style.width = player.ink + "%";

padStart(2, "0") は「2桁になるよう先頭を0で埋める」メソッド。これで秒が 5 ではなく 05 と表示され、時計らしくなります。

メインループで全部つなげる

いよいよ心臓部。requestAnimationFrame で毎秒約60回呼ばれるループの中で、「時間を進める→世界を動かす→表示を更新→描く」を繰り返します。

const MAX_DT = 0.033;  // 1フレームの上限。タブ切替で戻ったときの飛びすぎ防止

function loop(now) {
  requestAnimationFrame(loop);  // 次のフレームを先に予約
  const dt = Math.min(MAX_DT, (now - lastTime) / 1000);
  lastTime = now;

  if (!running) { draw(); return; }  // 試合中でなければ絵だけ描く

  // --- 時間を進める ---
  timeLeft -= dt;
  if (timeLeft <= HURRY_UP_SEC && !hurryPlayed) {
    hurryPlayed = true;
    el("timer").classList.add("hurry");  // 残り10秒で点滅
  }
  if (timeLeft <= 0) { updateScoreBar(); draw(); endGame(); return; }

  // --- 世界を進める ---
  for (const ch of characters) ch.update(dt);
  updateProjectiles(dt);
  updateParticles(dt);

  // --- 表示を更新して描く ---
  coverageTimer -= dt;
  if (coverageTimer <= 0) { coverageTimer = COVERAGE_INTERVAL; updateScoreBar(); }
  updateHUD();
  draw();
}

const dt = Math.min(MAX_DT, ...)重要なおまじない。ブラウザのタブを切り替えて戻ってきたとき、経過時間が巨大になってキャラや弾が一気にワープするのを防ぎます(アンダーテイル編・レーシング編でも同じ処理をしましたね)。

描画は奥から手前の順に重ねます。Canvasは「あとから描いたものが上」なので、描く順番がそのままレイヤーになります。

function draw() {
  drawFloor();                       // ① 床
  ctx.drawImage(inkCanvas, 0, 0);    // ② ためたインクをペタッと貼る
  drawObstacles();                   // ③ 障害物
  drawProjectiles();                 // ④ 飛んでいる弾
  drawParticles();                   // ⑤ 飛び散るつぶ
  for (const ch of characters) ch.draw();  // ⑥ キャラ
  drawCrosshair();                   // ⑦ 照準(いちばん手前)
}

描く順番=重なり順。照準を最後に描くから、どのキャラの上に重ねてもちゃんと見える、というわけです。

試合が終わったら endGame() で塗り率を比べ、勝ち・負け・引き分けをリザルト画面に出します。CSSの transition のおかげで、結果のバーがスーッと伸びる演出も自動でついてきます。

完成コード全文

index.htmlstyle.css は完成版がこの記事内に書かれています。script.js は約1200行(コメント込み)あるので、ここではファイルのダウンロードリンクを置きます。

全行に日本語コメントが付いているので、ダウンロードして読みながら写経するのもおすすめです。

動かしてみよう

ファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。「バトル開始!」ボタンでナワバリバトルがスタートです!

WASD/矢印キー:移動、マウス:ねらう、クリック(長押し):インクを撃つ、Shift長押し:イカ潜行(自分のインクの上で高速移動&インク回復)

90秒間、床をオレンジで塗りまくって、パープルチームより多くの面積を取れば勝ち! 敵のインクの上は遅くなるので、自分の道を塗りながら進むのがコツです。

もし動かない・エラーが出る場合は以下をチェック:

  • ファイルが同じフォルダに入っているか?
  • ファイル名は index.htmlstyle.cssscript.js か?
  • canvas の width/heightSTAGE_WIDTH/STAGE_HEIGHT の数字はそろっているか?
  • ブラウザの開発者ツール(F12)の Console タブにエラーが出ていないか?
  • BGMが鳴らない場合は、bgm.mp3 がフォルダにあるか? 一度クリックしたか?

次のステップ

動いたら、自分なりに改造して遊んでみよう。「数字を変える → 見た目を変える → 機能を増やす」の順に少しずつ攻めるのがおすすめです。

🟢 簡単(定数をいじるだけ)

  • GAME_TIME_SEC を 30 にして短期決戦に、180 にして長期戦に
  • SPEED.SQUID_OWN_INK を上げて、イカダッシュをもっと爽快に
  • WEAPON.DAMAGEFIRE_INTERVAL を変えて、強ブキ・弱ブキを作る
  • AI.AIM_ERROR を小さくして敵AIを強く、大きくして弱くする
  • TEAM_COLORS の色を書きかえて、自分の好きな2色対決に

🟡 中くらい(見た目と構成を変える)

  • obstacles に四角を足して、オリジナルのステージを作る
  • キャラの体(drawHumanBody)を描きかえて、別のキャラにする
  • キャラの数(setupCharacters)を増やして 4 vs 4 バトルに
  • インクのしぶきの形(paintInkLook)を変えて、塗りの質感を変える
  • 効果音のプリセット(SOUND_PRESETS)の数字をいじって、自分好みの音に

🔴 むずかしい(機能を追加する)

  • チャージャー(狙撃ブキ)を追加:長押しでチャージし、離すと遠くまで細い弾
  • スペシャルゲージを追加:塗った量がたまると強力な必殺技が撃てる
  • ステージにインクレールジャンプ台などのギミックを置く
  • AIに「味方が少ない場所を優先して塗る」など、もっと賢い判断をさせる
  • ローカルストレージに最高塗り率を記録してハイスコアに挑戦できるようにする

学べることが盛りだくさん

このゲーム1本で、こんなことが学べます:

  • Canvas APIでの図形描画(楕円・ベジェ曲線・円弧)と、見えないキャンバスの活用
  • クラスを使ったキャラ管理(プレイヤーとAIを同じ設計図で作る)
  • 2次元のマス目を1本の配列で扱うテクニック
  • ゲームAIの基本(目的地さがし・状態の切り替え・ヒステリシス)
  • 配列(弾・つぶ・キャラ)を「後ろから回して」安全に増減する方法
  • 三平方の定理を使った円形の塗りと当たり判定
  • 面積を数えて勝敗を決める、重い処理を間引いて軽くする工夫
  • WebAudioで音を合成する、イベントで入力を管理する

わからないことがあったら「JavaScript Canvas ○○」で検索してみよう。今回覚えたクラス・マス目管理・簡単なAIは、アクションでもシミュレーションでも使うゲーム作りの共通言語。このインクバトルを改造しているうちに、次のゲームのアイデアがきっと浮かんでくるよ!

野澤嘉孝

この記事を書いた人

野澤 嘉孝

ソフトウェアエンジニア。同志社大学理工学部を経て、京都大学大学院で核融合発電の基礎研究(プラズマ物理)に従事。在学中は高校生向け数学塾の講師を、大学院では大学生に対して物理実験の授業を担当し、延べ500名以上の学生をサポート。現在は業界特化型SaaSの開発に携わりながら、中高生向けプログラミングスクール Sandbox(サンドボックス)の運営を行う。

"自分で作れた"が生まれるスクール、Sandbox

Sandboxについて詳しく見る > すぐに相談したい方は → 無料面談に申し込む まずは気軽に → Peatixで無料体験イベントを見る(オンライン・無料)

入会金無料・いつでも退会OK・退会後もいつでも再入会できます