JavaScriptで疑似3Dレーシングゲームをゼロから作ろう!

JavaScriptで疑似3Dレーシングゲームをゼロから作ろう!

HTML・CSS・JavaScriptだけで、ブラウザで動くパイロット視点のレーシングゲームを完成させるチュートリアルです。 疑似3Dの道路、エンジン音、BGMまで全部自分で作って、本格的なゲームに仕上げよう。

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

📋 もくじ

  1. 完成するとどうなる?
  2. 準備するもの
  3. ファイル構成を作ろう
  4. HTMLを書こう
  5. CSSでデザインしよう
  6. JavaScriptの設計を決めよう
  7. 疑似3Dのしくみを理解しよう
  8. コースを作ろう
  9. 道路と空を描こう
  10. 木と他車を配置しよう
  11. キー入力と車の動き
  12. 衝突判定とラップ管理
  13. エンジン音とBGMを作ろう
  14. HUDとゲームループで仕上げ
  15. 完成コード全文
  16. 動かしてみよう
  17. 次のステップ

完成するとどうなる?

この記事を最後まで読むと、ブラウザ上で動く一人称視点のレーシングゲームが完成します。ただ車が走るだけじゃなくて、こんな機能がついています:

  • 道路がうねりながら奥に伸びる疑似3D描画(OutRun方式)
  • アクセル・ブレーキ・ハンドル・ターボ(Shift)の操作
  • 速度・ラップ・タイム・ベストタイムを表示するHUD
  • 緑→黄→赤に変化するRPMバーと1〜6速のギア表示
  • 他車や木との衝突判定
  • 速度に応じて音程が変わるエンジン音(録音なしの合成サウンド!)
  • ループ再生されるBGM(楽譜もコードで書く)
  • 3周走るとゴール、ベストタイムも記録される

使うのはHTML・CSS・JavaScriptの3つだけ。画像も音声ファイルも一切なしで、絵も音もぜんぶJavaScriptで作ります。

テトリスチュートリアルでCanvasの描画とゲームループに慣れた人は、次のステップとして「3Dっぽい表現」と「音の合成」の2つを一気に学べる、お得な題材です。

準備するもの

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

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

エンジン音やBGMが鳴るので、ヘッドホンかスピーカーもあるとテンションが上がります。

ファイル構成を作ろう

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

racing/
    index.html ─ 画面の構造
    style.css  ─ 見た目のデザイン
    script.js  ─ ゲーム本体(全部の動き)

テトリスのときと同じ「HTML・CSS・JS の3点セット」です。どんなゲームを作るときもこの構成は変わりません。

HTMLを書こう

index.html を開いて、以下のコードを書いてください。Canvasと、その上に重ねるHUDたちで構成されています。

<!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">
      <!-- ゲーム描画用キャンバス -->
      <canvas id="gameCanvas" width="960" height="600"></canvas>

      <!-- 画面上のHUD(速度・ラップ・タイム・ベスト) -->
      <div id="hud">
        <div class="hud-item">
          <span class="label">SPEED</span>
          <span id="speedValue" class="value">0</span>
          <span class="unit">km/h</span>
        </div>
        <div class="hud-item">
          <span class="label">LAP</span>
          <span id="lapValue" class="value">1 / 3</span>
        </div>
        <div class="hud-item">
          <span class="label">TIME</span>
          <span id="timeValue" class="value">0.00</span>
        </div>
        <div class="hud-item">
          <span class="label">BEST</span>
          <span id="bestValue" class="value">--.--</span>
        </div>
      </div>

      <!-- 画面下のタコメーター(RPMバー + ギア) -->
      <div id="tachometer">
        <div class="bar"><div id="rpmFill"></div></div>
        <div class="gear">
          <span class="label">GEAR</span>
          <span id="gearValue">1</span>
        </div>
      </div>

      <!-- スタート画面 -->
      <div id="startScreen" class="overlay">
        <h1>RACING</h1>
        <p>パイロット視点レーシング</p>
        <ul class="controls">
          <li><b>↑</b> / <b>W</b> : アクセル</li>
          <li><b>↓</b> / <b>S</b> : ブレーキ</li>
          <li><b>←→</b> / <b>A D</b> : ハンドル</li>
          <li><b>Shift</b> : ターボ</li>
          <li><b>M</b> : ミュート切替</li>
        </ul>
        <button id="startBtn">START</button>
      </div>

      <!-- ゴール画面 -->
      <div id="finishScreen" class="overlay hidden">
        <h1>FINISH!</h1>
        <p>TIME : <span id="finalTime">0.00</span> 秒</p>
        <p>BEST : <span id="finalBest">--.--</span> 秒</p>
        <button id="restartBtn">もう一度</button>
      </div>
    </div>

    <script src="script.js"></script>
  </body>
</html>

HTMLのポイント解説

主役はやっぱり <canvas id="gameCanvas">960×600ピクセルの画用紙に、JavaScriptが空と道路と車を毎フレーム描き直していきます。

HUD(Head-Up Display)は Canvasの上にHTML要素を重ねて配置しています。Canvas上に文字を書いてもいいんですが、HTMLとCSSのほうが楽なんです。フォントの調整も、色の変更も、CSSの普段使いの知識でできます。

<div class="overlay"> はスタート画面とゴール画面。最初に大きく表示して、START を押したら .hidden クラスを付けて非表示にする、という切替を後でJSからやります。

CSSでデザインしよう

style.css にダーク+ネオンシアンのHUDをデザインします。

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

html, body {
  width: 100%;
  height: 100%;
  background: #0a0a0a;
  color: #fff;
  font-family: "Helvetica Neue", "Hiragino Sans", "Yu Gothic", sans-serif;
  overflow: hidden;
}

body {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ゲームの枠(960×600の固定サイズ) */
#game {
  position: relative;
  width: 960px;
  height: 600px;
  box-shadow: 0 0 60px rgba(0, 200, 255, 0.25);
  border-radius: 6px;
  overflow: hidden;
}

/* キャンバス(背景は空のグラデーション) */
#gameCanvas {
  display: block;
  width: 100%;
  height: 100%;
  background: linear-gradient(#0d2a4d 0%, #1a4f8a 35%,
                              #f0a070 70%, #f8c89e 100%);
}

/* HUD */
#hud {
  position: absolute;
  top: 12px;
  left: 12px;
  right: 12px;
  display: flex;
  gap: 12px;
  justify-content: space-between;
  pointer-events: none;
  font-family: "Courier New", monospace;
}

.hud-item {
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(0, 220, 255, 0.4);
  border-radius: 4px;
  padding: 6px 12px;
  min-width: 120px;
  text-align: center;
}

.hud-item .label {
  display: block;
  font-size: 11px;
  letter-spacing: 2px;
  color: #6ee0ff;
}

.hud-item .value {
  font-size: 22px;
  font-weight: bold;
  color: #fff;
  text-shadow: 0 0 8px rgba(0, 220, 255, 0.6);
}

(残りのCSSは「完成コード全文」セクションに掲載しています)

CSSのポイント解説

position: absolute を使ってHUDをCanvasの上に重ねています。#game 側に position: relative を付けることで、HUDの位置はゲーム枠の左上を原点にして決まります。

pointer-events: none はクリックをHUDが奪わない設定。これがないと、HUDの下にあるCanvasのクリック判定が効かなくなります(今回は使ってないけど、覚えておくと便利)。

linear-gradient で空を描くのもポイント。CanvasのbackgroundをCSSで指定しておけば、JavaScriptが空を上書きで描く前の「初期色」になります。

JavaScriptの設計を決めよう

ここからが本番です! 全部で1500行くらいのコードを書くので、最初に設計を決めましょう。script.js は次の流れで構成します。

1. CONFIG(設定値) ── 速さや道路の幅など
2. DOM要素の取得  ── HTMLの部品を取ってくる
3. ゲームの状態    ── プレイヤーの位置・速度・ラップなど
4. コース作り      ── 道のセグメントを順番に並べる
5. キー入力        ── 矢印キーの状態を保持
6. オーディオ      ── Web Audio APIでエンジン音とBGM
7. 更新処理(update)── 1フレームの計算
8. 描画処理(render)── 道路・他車・自車を描く
9. HUD更新         ── 速度・ラップ・タイムの数字
10. ゲームループ   ── update→renderを繰り返す
11. 開始/ゴール    ── STARTやリスタートの処理

まず全体を即時関数 (() => { ... })() で囲むと、グローバル汚染を防げます。

(() => {
  "use strict";

  // ========== 1. CONFIG ==========
  const SEGMENT_LENGTH = 200;       // 1セグメントの奥行き
  const ROAD_WIDTH = 2000;          // 道路の半分の幅
  const LANES = 3;                  // 車線の数
  const RUMBLE_LENGTH = 3;          // 縁石が色を変える区間数

  const DRAW_DISTANCE = 300;        // 何セグメント先まで描くか
  const CAMERA_HEIGHT = 1000;       // 運転手の目の高さ
  const FIELD_OF_VIEW = 100;        // 視野角(度)
  const CAMERA_DEPTH = 1 / Math.tan((FIELD_OF_VIEW / 2) * Math.PI / 180);

  const MAX_SPEED = SEGMENT_LENGTH * 60;
  const ACCEL = MAX_SPEED / 5;
  const DECEL = -MAX_SPEED / 6;
  const BRAKING = -MAX_SPEED;
  const TURBO_BOOST = 1.35;
  const CENTRIFUGAL = 0.3;          // カーブで外側に膨らむ強さ
  const TOTAL_LAPS = 3;

  // ========== 2. DOM要素 ==========
  const canvas = document.getElementById("gameCanvas");
  const ctx = canvas.getContext("2d");
  const W = canvas.width;
  const H = canvas.height;

  // ========== 3. ゲームの状態 ==========
  const player = {
    x: 0,        // 横位置(-1〜+1が道路の中、それ以上はコース外)
    z: 0,        // コース上の進行距離
    speed: 0,
  };

  const state = {
    running: false,
    finished: false,
    elapsed: 0,
    lap: 1,
    bestTime: null,
    courseLength: 0,
  };

  // ...続く
})();

CONFIGをまとめておくメリット

ゲームの「遊び心地」を決める数字を最初にまとめておくと、後から調整が一瞬で済みます。たとえば:

  • MAX_SPEED を2倍 → 爆速ゲームに
  • TURBO_BOOST を 2.5 → 暴れ馬みたいに加速
  • CENTRIFUGAL を 0 → カーブでフラフラしない簡単モード
  • TOTAL_LAPS を 1 → タイムアタック専用に

ゲームの楽しさを試行錯誤するときは、CONFIGの数字をいじるだけで遊び心地がガラッと変わることを覚えておいてください。

疑似3Dのしくみを理解しよう

このゲームの一番の見どころは「3Dに見える道路」です。でも、本当に3D空間を計算しているわけではありません

💡 OutRun方式:道路を「台形のスライス」に分解する

考え方はシンプル。コースを SEGMENT_LENGTH = 200 の奥行きを持つ短い区間(セグメント)に切り刻み、それぞれを台形として奥から手前へ順番に描きます。

    ┌──────┐     ← 遠くのセグメント(細い)
    │      │
   ┌┴──────┴┐
   │        │
  ┌┴────────┴┐  ← 中間
  │          │
 ┌┴──────────┴┐
 │            │
┌┴────────────┴┐ ← 手前(太い)

「遠くは細く、近くは太く」というルールで描くだけで立体に見える、というのがOutRun方式の発明です。1986年のアーケードゲーム「アウト・ラン」で採用されてヒットした手法で、いまでもブラウザでパッと動かしたいときの定番です。

縮小率の公式

各セグメントをどれくらい縮めて描くかは、たった1つの式で決まります:

scale = カメラの奥行き ÷ そのセグメントまでの距離

距離が遠いほど分母が大きくなる → scaleが小さくなる → 細く・低く描かれる、というしくみです。中学・高校で習う「遠近法」と同じ原理を、コードで再現しているだけです。

コースを作ろう

セグメントを並べてコースを組み立てます。1つのセグメントは「始点と終点の3D座標」「カーブの強さ」「」を持ちます。

const segments = [];

function lastY() {
  return segments.length === 0 ? 0 : segments[segments.length - 1].p2.world.y;
}

// セグメントを1つ追加する
function addSegment(curve, y) {
  const n = segments.length;
  segments.push({
    index: n,
    p1: { world: { y: lastY(), z: n * SEGMENT_LENGTH },
          camera: {}, screen: {} },
    p2: { world: { y: y, z: (n + 1) * SEGMENT_LENGTH },
          camera: {}, screen: {} },
    curve,
    sprites: [],   // この区間に置く木など
    cars: [],      // この区間にいる他車
    color: Math.floor(n / RUMBLE_LENGTH) % 2 ? COLORS.DARK : COLORS.LIGHT,
  });
}

p1p2 がセグメントの「始点」と「終点」。camerascreen は最初は空のオブジェクトで、毎フレームの描画で project 関数が値を埋めます。

Math.floor(n / RUMBLE_LENGTH) % 2 で、3セグメントごとに色を切り替えて縞模様にしています。

カーブと坂をなめらかにつなぐ

「だんだん曲がりはじめ → カーブを維持 → だんだん戻る」の3段階を作る関数を用意します。

function easeIn(a, b, percent) {
  return a + (b - a) * Math.pow(percent, 2);
}

function easeInOut(a, b, percent) {
  return a + (b - a) * (-Math.cos(percent * Math.PI) / 2 + 0.5);
}

function addRoad(enter, hold, leave, curve, y) {
  const startY = lastY();
  const endY = startY + y * SEGMENT_LENGTH;
  const total = enter + hold + leave;
  for (let i = 0; i < enter; i++)
    addSegment(easeIn(0, curve, i / enter),
               easeInOut(startY, endY, i / total));
  for (let i = 0; i < hold; i++)
    addSegment(curve, easeInOut(startY, endY, (enter + i) / total));
  for (let i = 0; i < leave; i++)
    addSegment(easeInOut(curve, 0, i / leave),
               easeInOut(startY, endY, (enter + hold + i) / total));
}

easeIn(だんだん始まる)と easeInOut(なめらかに上下)はゲームやアニメーションでよく使うイージング関数です。これを使うと、カクッと曲がりはじめるのではなく、自然なカーブが作れます。

コースを組み立てる

頻繁に使う形をショートカット関数にして、それを並べるだけでコースができます。

function addStraight(num = 50) { addRoad(num, num, num, 0, 0); }
function addCurve(num = 50, curve = 2, y = 0) {
  addRoad(num, num, num, curve, y);
}
function addHill(num = 50, height = 30) {
  addRoad(num, num, num, 0, height);
}
function addSCurves() {
  addRoad(30, 30, 30, -2, 0);
  addRoad(30, 30, 30, 3, 25);
  addRoad(30, 30, 30, 4, 0);
  addRoad(30, 30, 30, -3, -25);
  addRoad(30, 30, 30, -2, 0);
}

function buildCourse() {
  segments.length = 0;
  addStraight(40);
  addLowRollingHills();
  addSCurves();
  addCurve(50, 3, 0);
  addStraight(30);
  addHill(40, 50);
  addCurve(60, -3, 0);
  addSCurves();
  addCurve(60, 2, 30);
  addStraight(40);
  addLowRollingHills();
  addStraight(30);

  // スタート/フィニッシュラインの色を上書き
  for (let n = 0; n < RUMBLE_LENGTH; n++)
    segments[n].color = COLORS.START;
  for (let n = 0; n < RUMBLE_LENGTH; n++)
    segments[segments.length - 1 - n].color = COLORS.FINISH;

  placeTrees();
  placeOpponentCars();
}

buildCourse の中身はコースの設計図。お絵描きと同じ感覚で、好きな順番に関数を呼ぶだけで自分だけのコースが作れます。

道路と空を描こう

セグメントを画面に表示するには、3D座標を2Dの画面座標に変換する project() 関数が必要です。

function project(p, cameraX, cameraY, cameraZ,
                 cameraDepth, width, height, roadWidth) {
  // ① ワールド座標 → カメラ座標
  p.camera.x = (p.world.x || 0) - cameraX;
  p.camera.y = (p.world.y || 0) - cameraY;
  p.camera.z = (p.world.z || 0) - cameraZ;

  // ② 縮小率(遠いほど小さい)
  p.screen.scale = cameraDepth / p.camera.z;

  // ③ 画面の中心 + (scale × 位置 × 画面サイズ/2)
  p.screen.x = Math.round(width  / 2 + p.screen.scale * p.camera.x * width  / 2);
  p.screen.y = Math.round(height / 2 - p.screen.scale * p.camera.y * height / 2);
  p.screen.w = Math.round(p.screen.scale * roadWidth * width / 2);
}

この3ステップを覚えれば疑似3Dは怖くありません。さっき説明した scale = camDepth ÷ z がここに出てきます。

1つのセグメントを4層に分けて描く

function drawRoadSegment(x1, y1, w1, x2, y2, w2, color) {
  // 1. 草
  ctx.fillStyle = color.grass;
  ctx.fillRect(0, y2, W, y1 - y2);

  // 2. 縁石(左右)
  drawPolygon(x1 - w1 - rumble1, y1, x1 - w1, y1,
              x2 - w2, y2, x2 - w2 - rumble2, y2, color.rumble);
  drawPolygon(x1 + w1 + rumble1, y1, x1 + w1, y1,
              x2 + w2, y2, x2 + w2 + rumble2, y2, color.rumble);

  // 3. 路面(台形)
  drawPolygon(x1 - w1, y1, x1 + w1, y1,
              x2 + w2, y2, x2 - w2, y2, color.road);

  // 4. 車線
  for (let lane = 1; lane < LANES; lane++) {
    // ... 中央のラインを引く
  }
}

セグメント1つは「草 → 縁石 → 路面 → 車線」の順に4層を塗り重ねます。下から重ねていくのは、絵を描くときと同じ感覚です。

空のグラデーションと太陽

function drawSky() {
  const grad = ctx.createLinearGradient(0, 0, 0, H * 0.6);
  grad.addColorStop(0, "#0d2a4d");
  grad.addColorStop(0.5, "#1a4f8a");
  grad.addColorStop(1, "#f0a070");
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  // 太陽
  ctx.fillStyle = "rgba(255, 230, 200, 0.9)";
  ctx.beginPath();
  ctx.arc(W * 0.7, H * 0.42, 60, 0, Math.PI * 2);
  ctx.fill();
}

createLinearGradient で空のグラデーションを作って、arc で円を描いて太陽にします。たったこれだけで、夕暮れっぽい雰囲気が出ます。

木と他車を配置しよう

道路だけだとさみしいので、ランダムに木と他車を置きます。

function placeTrees() {
  for (let n = 10; n < segments.length;
       n += 4 + Math.floor(Math.random() * 6)) {
    const side = Math.random() < 0.5 ? -1 : 1;
    const offset = side * (1.2 + Math.random() * 2.4);  // 道路の外側
    const type = Math.random() < 0.5 ? "tree" : "bush";
    segments[n].sprites.push({ offset, type });
  }
}

function placeOpponentCars() {
  for (let n = 30; n < segments.length - 30;
       n += 90 + Math.floor(Math.random() * 120)) {
    const lane = Math.floor(Math.random() * LANES) - 1;
    const offset = lane * 0.6;
    const speed = MAX_SPEED / 4 + Math.random() * (MAX_SPEED / 3);
    const color = `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`;
    segments[n].cars.push({ offset, z: 0, speed, color });
  }
}

Math.random() を活用して、木の位置・種類、車の車線・速度・色をランダムに決めています。色は hsl() を使うと、ランダムな色相を指定するだけでカラフルなバリエーションが作れて便利です。

それぞれの絵は、drawTreeOrBushdrawOpponentCar で描きます(詳細は完成コードを参照)。遠近によるサイズ調整は seg.p1.screen.scale を掛けるだけでOK。スプライト(2D画像扱いのオブジェクト)は遠ければ小さく、近ければ大きく自動的にスケールします。

キー入力と車の動き

「押している間ずっと動く」を実現するには、キーの状態をオブジェクトで覚えておくのがコツです。

const keys = {
  up: false, down: false, left: false, right: false, turbo: false,
};

function onKey(e, down) {
  const k = e.key.toLowerCase();
  if      (k === "arrowup"    || k === "w") keys.up    = down;
  else if (k === "arrowdown"  || k === "s") keys.down  = down;
  else if (k === "arrowleft"  || k === "a") keys.left  = down;
  else if (k === "arrowright" || k === "d") keys.right = down;
  else if (k === "shift") keys.turbo = down;
  else return;
  e.preventDefault();
}

window.addEventListener("keydown", e => onKey(e, true));
window.addEventListener("keyup",   e => onKey(e, false));

💡 押した瞬間だけ動かすとゲームにならない理由

もし「キーを押した瞬間に1回だけ動かす」と、押しっぱなしでもじわじわ進まないし、前進しながらハンドルも切る、ができません。keydown でtrue、keyup でfalseを記録しておけば、update() は毎フレームこの値を見るだけで自然な操作感が作れます。複数キー同時押しも自動でOKになります。

アクセル・ブレーキの計算

function applyAccelAndBrake(dt) {
  if (keys.up) {
    const boost = keys.turbo ? TURBO_BOOST : 1;
    player.speed += ACCEL * boost * dt;
  } else if (keys.down) {
    player.speed += BRAKING * dt;
  } else {
    player.speed += DECEL * dt;
  }

  // コース外を走ると追加で減速
  if ((player.x < -1 || player.x > 1) && player.speed > OFFROAD_LIMIT) {
    player.speed += OFFROAD_DECEL * dt;
  }

  // 速度を 0〜MAX_SPEED の範囲に挟む
  player.speed = Math.max(0, Math.min(MAX_SPEED, player.speed));
}

× dt を必ず付けるのがゲームプログラミングの基本ルール。dt は前フレームから経った秒数で、これを掛けることで「画面が速いPCでも遅いPCでも同じ速さ」になります。

ハンドルと遠心力

function applySteering(dt, playerSegment) {
  const speedPercent = player.speed / MAX_SPEED;
  const dx = dt * 2 * speedPercent;   // 速いほどよく曲がる

  if (keys.left)  player.x -= dx;
  if (keys.right) player.x += dx;

  // カーブを走ると外側に押される
  player.x -= dx * speedPercent * playerSegment.curve * CENTRIFUGAL;

  // コース外にも一定範囲は出られる
  player.x = Math.max(-2.5, Math.min(2.5, player.x));
}

dxspeedPercent を掛けているので、停止中はハンドルが効かない仕様になっています(実車と同じ)。遠心力も同じ dx から計算するので、速度・カーブの強さ・遠心力の3つが連動します。

衝突判定とラップ管理

衝突判定は、難しいことを考えずに「横位置の差の絶対値」だけで決めます。

function checkCollisions(dt, playerSegment) {
  // 他車との衝突
  for (const car of playerSegment.cars) {
    if (Math.abs(player.x - car.offset) < CAR_HIT_WIDTH) {
      player.speed = Math.min(player.speed, OFFROAD_LIMIT);
      player.x += (player.x < car.offset ? -1 : 1) * dt * 0.5;
      break;
    }
  }
  // 木との衝突(もっと激しく減速)
  for (const sp of playerSegment.sprites) {
    if (Math.abs(player.x - sp.offset) < TREE_HIT_WIDTH) {
      player.speed = Math.min(player.speed, OFFROAD_LIMIT / 2);
      break;
    }
  }
}

💡 「同じセグメント」しか調べないのがミソ

プレイヤーがいるセグメントの中にいる他車・木だけ調べれば十分。コース全体の物体を毎フレーム調べる必要はないので、すごく速く動きます。これはゲーム開発で重要な「空間分割」というテクニックの一番シンプルな形です。

ラップ管理

function advancePlayerZ(dt) {
  player.z += player.speed * dt;
  if (player.z >= state.courseLength) {
    player.z -= state.courseLength;
    state.lap += 1;
    if (state.lap > TOTAL_LAPS) finish();
  }
}

進行距離 player.z がコース1周の長さを超えたら、ぐるっと先頭に戻してラップ数を1増やす。これだけ。3周走ったら finish() を呼んでゴール処理に入ります。

エンジン音とBGMを作ろう

ここがこのゲームで一番面白いところかもしれません。音は録音されたファイルを使わず、Web Audio APIで合成します。

Web Audio APIの基本

音は「音を作る部品(オシレーター)」と「音量つまみ(gain)」をつないで作ります。

oscillator → gain → destination(スピーカー)

オシレーターには sawtooth(ノコギリ波)/ square(四角波)/ triangle(三角波)/ sine(正弦波) の4種類があります。エンジン音みたいなザラっとした音には sawtooth と square が向いています。

const audio = { ctx: null, enabled: false, master: null, engine: {} };

function initAudio() {
  const AC = window.AudioContext || window["webkitAudioContext"];
  audio.ctx = new AC();
  audio.master = audio.ctx.createGain();
  audio.master.gain.value = 0.55;
  audio.master.connect(audio.ctx.destination);

  setupEngineSound();
  setupBGM();
  audio.enabled = true;
}

💡 AudioContextはユーザー操作のあとでしか作れない

ブラウザのセキュリティ上、AudioContextユーザーが画面をクリックしたあとでないと作れません。だから initAudio() はSTARTボタンを押したときに初めて呼びます。

エンジン音を作る

function setupEngineSound() {
  const engineGain = audio.ctx.createGain();
  engineGain.gain.value = 0;
  engineGain.connect(audio.master);

  // 主音(ノコギリ波)
  const osc1 = audio.ctx.createOscillator();
  osc1.type = "sawtooth";
  osc1.frequency.value = 60;
  osc1.connect(engineGain);
  osc1.start();

  // 倍音(四角波で少し弱め)
  const osc2 = audio.ctx.createOscillator();
  osc2.type = "square";
  osc2.frequency.value = 90;
  const osc2Gain = audio.ctx.createGain();
  osc2Gain.gain.value = 0.35;
  osc2.connect(osc2Gain).connect(engineGain);
  osc2.start();

  // LFO(周波数を細かく揺らして「ヴルルル」を作る)
  const lfo = audio.ctx.createOscillator();
  const lfoGain = audio.ctx.createGain();
  lfo.frequency.value = 7;
  lfoGain.gain.value = 4;
  lfo.connect(lfoGain).connect(osc1.frequency);
  lfo.start();

  audio.engine.osc1 = osc1;
  audio.engine.gain = engineGain;
}

LFO(Low Frequency Oscillator) が秘密兵器。1秒に7回、メインの音程を上下に揺らすことで、機械っぽい「ヴルルルル…」というアイドリング音が再現できます。

速度に応じて音程を変える

function updateEngineSound() {
  if (!audio.enabled) return;
  const t = audio.ctx.currentTime;
  const pct = player.speed / MAX_SPEED;
  const target = 55 + (240 - 55) * pct;  // 55Hz 〜 240Hz

  audio.engine.osc1.frequency.setTargetAtTime(target, t, 0.08);
  audio.engine.osc2.frequency.setTargetAtTime(target * 1.5, t, 0.08);
}

setTargetAtTime は「0.08秒かけて滑らかに目標値に向かう」予約。.value = target で直接代入してしまうとブチッとノイズが入るので、必ずこれを使います。

BGMの楽譜をコードで書く

BGMもJavaScriptで合成します。まずメロディをMIDIノート番号で配列にします。

const PATTERN_MEL = [
  64, null, 67, null, 71, null, 74, 71,
  72, null, 71, null, 67, null, 64, null,
  62, null, 65, null, 69, null, 72, 69,
  71, 69, 67, 65, 64, null, 67, null,
];

const PATTERN_BASS = [40, 40, 47, 43, 36, 36, 38, 38];
const PATTERN_ARP  = [64, 67, 71, 74, 71, 67, 64, 67];

// MIDIノート番号(60=ド)→周波数(Hz)
function midiToFreq(n) {
  return 440 * Math.pow(2, (n - 69) / 12);
}

null は休符。MIDIノート番号は 60がドで、12増えると1オクターブ上がります。

「予約再生」でリズムを正確に

setInterval は遅れることがあるので、0.25秒先まで音を予約しておく方式で正確に鳴らします。

function scheduleBGM() {
  const lookahead = 0.25;
  while (audio.bgm.time < audio.ctx.currentTime + lookahead) {
    const t = audio.bgm.time;
    const step = audio.bgm.step;

    const m = PATTERN_MEL[step % PATTERN_MEL.length];
    if (m != null) playTone(midiToFreq(m), t, STEP * 0.85, "square", 0.07);

    if (step % 2 === 0) {
      const b = PATTERN_BASS[(step / 2) % PATTERN_BASS.length];
      playTone(midiToFreq(b), t, BEAT * 0.9, "sawtooth", 0.09);
    }

    if (step % 4 === 0) playKick(t);
    playHat(t);

    audio.bgm.time += STEP;
    audio.bgm.step += 1;
  }
}

setInterval(scheduleBGM, 60);

AudioContext時間軸を正確に持っているので、「t秒後に鳴らせ」と未来の予約ができます。これで setInterval がちょっと遅れても、リズムはピッタリ揃います。

HUDとゲームループで仕上げ

最後の仕上げです。HUDの数字は、速度の%から全部計算できます。

function updateHUD() {
  const pct = player.speed / MAX_SPEED;

  speedEl.textContent = Math.round(pct * 280);
  timeEl.textContent  = state.elapsed.toFixed(2);
  bestEl.textContent  = state.bestTime == null
                       ? "--.--" : state.bestTime.toFixed(2);

  rpmFill.style.width = `${pct * 100}%`;
  const gear = Math.min(6, Math.max(1, Math.ceil(pct * 100 / 17)));
  gearEl.textContent = gear;
}

速度%を求めてしまえば、km/h・RPMバー・ギア番号は全部それの掛け算と切り上げで出せます。RPMバーは rpmFill.style.width を変えるだけ。CSSのグラデーションで緑→黄→赤に変わるのは前に書いたCSSの仕事です。

ゲームループ

let lastTime = 0;

function gameLoop(now) {
  if (!lastTime) lastTime = now;
  let dt = (now - lastTime) / 1000;
  if (dt > 0.1) dt = 0.1;   // タブ切替で巨大な値にならないよう制限
  lastTime = now;

  update(dt);
  render();
  updateHUD();
  updateEngineSound();

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

requestAnimationFrame毎秒約60回、update→render→updateHUD→updateEngineSound を繰り返す。これがゲームの心臓です。

if (dt > 0.1) dt = 0.1重要なおまじない。ブラウザのタブを切り替えていたあいだ更新が止まると、戻ってきたときに dt が巨大な値になって車がワープしてしまいます。これを防いでいます。

完成コード全文

以下が3ファイルの完成コードです。そのままコピペしてOK! 動いたら、CONFIGの数字をいじったりコースを変えたりして遊んでみてください。

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">
      <canvas id="gameCanvas" width="960" height="600"></canvas>

      <div id="hud">
        <div class="hud-item">
          <span class="label">SPEED</span>
          <span id="speedValue" class="value">0</span>
          <span class="unit">km/h</span>
        </div>
        <div class="hud-item">
          <span class="label">LAP</span>
          <span id="lapValue" class="value">1 / 3</span>
        </div>
        <div class="hud-item">
          <span class="label">TIME</span>
          <span id="timeValue" class="value">0.00</span>
        </div>
        <div class="hud-item">
          <span class="label">BEST</span>
          <span id="bestValue" class="value">--.--</span>
        </div>
      </div>

      <div id="tachometer">
        <div class="bar"><div id="rpmFill"></div></div>
        <div class="gear">
          <span class="label">GEAR</span>
          <span id="gearValue">1</span>
        </div>
      </div>

      <div id="startScreen" class="overlay">
        <h1>RACING</h1>
        <p>パイロット視点レーシング</p>
        <ul class="controls">
          <li><b>↑</b> / <b>W</b> : アクセル</li>
          <li><b>↓</b> / <b>S</b> : ブレーキ</li>
          <li><b>←→</b> / <b>A D</b> : ハンドル</li>
          <li><b>Shift</b> : ターボ</li>
          <li><b>M</b> : ミュート切替</li>
        </ul>
        <button id="startBtn">START</button>
      </div>

      <div id="finishScreen" class="overlay hidden">
        <h1>FINISH!</h1>
        <p>TIME : <span id="finalTime">0.00</span> 秒</p>
        <p>BEST : <span id="finalBest">--.--</span> 秒</p>
        <button id="restartBtn">もう一度</button>
      </div>
    </div>

    <script src="script.js"></script>
  </body>
</html>

style.css(完成版)

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

html, body {
  width: 100%;
  height: 100%;
  background: #0a0a0a;
  color: #fff;
  font-family: "Helvetica Neue", "Hiragino Sans", "Yu Gothic", sans-serif;
  overflow: hidden;
}

body {
  display: flex;
  align-items: center;
  justify-content: center;
}

#game {
  position: relative;
  width: 960px;
  height: 600px;
  box-shadow: 0 0 60px rgba(0, 200, 255, 0.25);
  border-radius: 6px;
  overflow: hidden;
}

#gameCanvas {
  display: block;
  width: 100%;
  height: 100%;
  background: linear-gradient(#0d2a4d 0%, #1a4f8a 35%,
                              #f0a070 70%, #f8c89e 100%);
}

#hud {
  position: absolute;
  top: 12px; left: 12px; right: 12px;
  display: flex;
  gap: 12px;
  justify-content: space-between;
  pointer-events: none;
  font-family: "Courier New", monospace;
}

.hud-item {
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(0, 220, 255, 0.4);
  border-radius: 4px;
  padding: 6px 12px;
  min-width: 120px;
  text-align: center;
}

.hud-item .label {
  display: block;
  font-size: 11px;
  letter-spacing: 2px;
  color: #6ee0ff;
}

.hud-item .value {
  font-size: 22px;
  font-weight: bold;
  color: #fff;
  text-shadow: 0 0 8px rgba(0, 220, 255, 0.6);
}

.hud-item .unit {
  font-size: 11px;
  color: #aaa;
  margin-left: 4px;
}

#tachometer {
  position: absolute;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 14px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(0, 220, 255, 0.4);
  border-radius: 4px;
  padding: 8px 16px;
  pointer-events: none;
  font-family: "Courier New", monospace;
}

.bar {
  width: 240px;
  height: 14px;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 2px;
  overflow: hidden;
}

#rpmFill {
  width: 0%;
  height: 100%;
  background: linear-gradient(90deg, #00ff88 0%, #ffe44d 60%, #ff3030 100%);
  transition: width 0.05s linear;
}

.gear { text-align: center; }
.gear .label {
  display: block;
  font-size: 10px;
  color: #6ee0ff;
  letter-spacing: 2px;
}
.gear #gearValue { font-size: 22px; font-weight: bold; }

.overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.7);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  text-align: center;
  z-index: 10;
}

.overlay.hidden { display: none; }

.overlay h1 {
  font-size: 72px;
  letter-spacing: 12px;
  color: #fff;
  text-shadow: 0 0 20px #00bfff, 0 0 40px #00bfff;
}

.overlay p { font-size: 16px; color: #ddd; }

.controls {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: 6px 24px;
  margin: 8px 0 16px;
  font-size: 14px;
  color: #ccc;
}

.controls b {
  display: inline-block;
  min-width: 26px;
  padding: 1px 6px;
  margin-right: 6px;
  background: #222;
  border: 1px solid #555;
  border-radius: 3px;
  color: #6ee0ff;
  font-family: "Courier New", monospace;
}

button {
  margin-top: 12px;
  padding: 12px 36px;
  font-size: 18px;
  letter-spacing: 4px;
  color: #fff;
  background: linear-gradient(180deg, #007acc, #004080);
  border: 1px solid #00bfff;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
}

button:hover {
  background: linear-gradient(180deg, #00a0ff, #0060b0);
  box-shadow: 0 0 20px rgba(0, 191, 255, 0.6);
}

script.js(完成版)

script.js は1500行を超える長さなので、ここではファイルのダウンロードリンクを置きます。

動かしてみよう

3つのファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。「START」を押せばエンジン音とBGMが鳴って、レースがスタートします!

↑ / W:アクセル、↓ / S:ブレーキ、← → / A D:ハンドル、Shift:ターボ、M:ミュート

もし音が鳴らない・画面が真っ黒・エラーが出る場合は以下をチェック:

  • 3つのファイルが同じフォルダに入っているか?
  • ファイル名は index.htmlstyle.cssscript.js か?
  • ブラウザの開発者ツール(F12)の Console タブにエラーが出ていないか?
  • 音が出ない場合は、STARTボタンを一度クリックしたか?(ブラウザの仕様で、ユーザー操作なしには音が鳴らせません)

次のステップ

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

🟢 簡単(CONFIGをいじるだけ)

  • MAX_SPEED を上げて爆速モードに
  • TOTAL_LAPS を 1 にしてタイムアタック専用ゲームに
  • ROAD_WIDTH を 3000 にして広い道路に
  • TURBO_BOOST を 2.5 にしてターボを暴れさせる
  • CENTRIFUGAL を 0 にして簡単モード

🟡 中くらい(コースや色を変える)

  • buildCourse に好きな順番で関数を呼んで、自分だけのコースを作る
  • addCurvecurve を ±5 にして急カーブを作る
  • addHill の高さを 100 にしてジャンプ台みたいな坂を作る
  • COLORSLIGHT DARK の色を変えて、雪のコースや砂漠のコースに
  • BGMの PATTERN_MEL を書き換えて自分の好きなメロディに

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

  • ニトロボタン(Cキー)を追加して、押した瞬間に最高速まで一気に上がる
  • コインを道路に置いて、取るとスコアが加算される
  • ライバル車にぶつかると順位入れ替えシステムを実装
  • 夜コースモード(空と色をガラッと切り替え)を追加
  • スマホで遊べるタッチ操作を追加

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

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

  • Canvas APIでの2D描画
  • 疑似3D(OutRun方式)の数学
  • ゲームループと requestAnimationFrame
  • 物理シミュレーションの初歩(速度・遠心力)
  • 衝突判定の考え方
  • Web Audio API での音の合成
  • MIDIノート番号と周波数の関係(音楽×プログラミング)
  • イベントリスナーとキー入力管理

わからないことがあったら「JavaScript Canvas ○○」や「Web Audio API ○○」で検索してみよう。どっちもWebゲーム開発で長く使える知識なので、覚えておくと他のゲームもどんどん作れるようになるよ!

野澤嘉孝

この記事を書いた人

野澤 嘉孝

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

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

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

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