Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

プログラミング初心者でもシューティングゲームが作りたい!!!

Last updated at Posted at 2025-08-21

この記事では、プログラミングを初めて学ぶ方向けに、HTML・CSS・JavaScriptの超入門から、Three.js を使ったキーボード操作のシューティングゲームづくりまでを、課題形式で少しずつ完成させていきましょう!
最終的にはブラウザで遊べる作品になります!!

  • 学ぶ内容の流れ
    • プログラミングをするにあたっての心構え
    • ターミナル(コマンドプロンプト)とは?
    • WSLとは?Windowsユーザー向けWSLインストール方法
    • Homebrewのインストール(macOS)
    • UNIXコマンドを触ってみよう(mkdir, touch, cd, ls, ., ..)
    • VS Codeとは?インストール方法
    • GitHubアカウントの作り方
    • Gitって何?基本コマンド(add/commit/push/pull)
    • ブランチ運用(main/develop/featブランチ)
    • HTML・CSS・JavaScriptの超入門(コード例あり)
    • ライブラリとは?Three.jsとは?
    • Three.jsでシューティングゲームを作ってみよう(課題ベース)

参考コード例(教材)
このgithubリポジトリにあるソースコードを使用して進めていきます。


プログラミングをするにあたって

  • 最初から全部を理解しなくてOK。動かしながら「わからない」を少しずつ減らしていきます。
  • 小さく試して、小さく直すのがコツです。動いたら保存、壊れたら戻す。
  • 「手で書く/打つ」ことが上達の近道です。コピペだけに頼らないで、タイプして感覚を身につけましょう。

ターミナル(コマンドプロンプト)とは?

コンピュータに文字で命令を出すアプリです。以下の名前で呼ばれることもあります。

  • macOS: Terminal(ターミナル)
  • Windows: コマンドプロンプト / PowerShell / Windows Terminal

ファイルやフォルダを作ったり移動したり、Gitなどのコマンドを実行する時に使います。

WSLとは?(Windowsユーザー向け)

WSL(Windows Subsystem for Linux)は、Windows上でLinux環境を使える機能です。Web開発の多くはLinux系のコマンドが便利なので、WindowsでもWSLを使うのがおすすめです。

WSLのインストール方法(Windows 11/10)

  1. 管理者としてPowerShell(またはWindows Terminal)を開く
  2. 次のコマンドを実行
wsl --install
  1. 指示に従って再起動し、ディストリビューション(通常はUbuntu)を初期設定します(ユーザー名・パスワード設定など)。

詳しくは公式ドキュメントを参照してください:Microsoft Docs - WSL

Homebrewのインストール(macOS)

HomebrewはmacOS向けのパッケージマネージャーです。開発ツールのインストールが簡単になります。

  1. ターミナルを開く
  2. 次をそのまま貼り付けて実行
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  1. 表示される指示に従ってPATH設定を行います(インストール後のメッセージをよく読みましょう)。

公式サイト:Homebrew

UNIXコマンドを触ってみよう

  • pwd: 今いる場所を表示
  • ls: フォルダ内のファイル一覧
  • cd フォルダ名: フォルダへ移動(cd .. は一つ上へ、. は現在地を表す)
  • mkdir フォルダ名: フォルダを作る
  • touch ファイル名: 空ファイルを作る(例: touch index.html
pwd
mkdir my_project
cd my_project
touch index.html
ls

現在のディレクトリのエクスプローラーを開くには以下のコマンドで

explorer.exe .

VS Codeとは?インストール方法

VS Codeは、マイクロソフト製の人気コードエディタです。

  • 公式サイトからダウンロード:Visual Studio Code
  • 日本語化拡張「Japanese Language Pack」や、拡張「ESLint」「Prettier」などを入れると便利です。

GitHubアカウントの作り方

  1. 公式サイトへアクセス:GitHub
  2. Sign up(無料登録)からユーザー名・メール・パスワードを入力
  3. メール認証を済ませ、ログインします。

Gitって何?

Gitは「変更の履歴を記録し、複数人で安全に開発できる」ツールです。

  • git add: 変更をステージ(次のcommit候補に)
  • git commit: 履歴として確定
  • git push/pull: リモート(GitHub)と同期
git add .
git commit -m "first commit"
git branch -M main
git remote add origin https://github.com/あなたのユーザー名/ThreeJS_HandsOn.git
git push -u origin main

git add / git commit / git push / git pull

  • git add .: 全部の変更を追加
  • git commit -m "メッセージ": 変更を履歴に保存
  • git push: GitHubへアップロード
  • git pull: GitHubから最新を取り込み

ブランチ運用(main / develop / featブランチ)

  • main: リリース用の安定ブランチ
  • develop: 開発の基盤となるブランチ
  • feat/○○: 新機能ごとの作業ブランチ
git switch -c develop      # developブランチを作って切り替え
git switch -c feat/player  # 機能ごとのブランチ

# 作業 -> add/commit -> GitHubへ
git push -u origin feat/player

gitに関して詳しく知りたい場合はこちらから


HTMLとは?(簡単なコード例)

HTMLはWebページの「骨組み(構造)」を作る言語です。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>はじめてのHTML</title>
  </head>
  <body>
    <h1>見出し</h1>
    <p>文章の段落です。</p>
  </body>
  </html>

CSSとは?(簡単なコード例)

CSSはWebページの「見た目(デザイン)」を整える言語です。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>
      body { font-family: system-ui, sans-serif; margin: 24px; }
      h1 { color: royalblue; }
      .box { width: 120px; height: 120px; background: salmon; border-radius: 8px; }
    </style>
    <title>はじめてのCSS</title>
  </head>
  <body>
    <h1>色を変えてみよう</h1>
    <div class="box"></div>
  </body>
  </html>

JavaScriptとは?

JavaScriptはWebページに「動き(ロジック)」を与える言語です。ライブラリを一切使わず、概念ごとに小さなHTMLファイルに分けて学びます。以下のファイルをブラウザで開き、開発者ツールのコンソールに出力を確認してください。

  • basics/01_console_log.html: console.log で出力してみる
  • basics/02_variables.html: 変数(const/let)
  • basics/03_if.html: if(条件分岐)
  • basics/04_for.html: for(繰り返し)
  • basics/05_function.html: function(関数定義と呼び出し)
  • basics/06_class_and_instance.html: class(クラス)とインスタンス

各ファイルは <script> タグ内のみで完結しており、結果はコンソールとページ上に表示されます。

各課題のコード例

  • 課題1: console.logでコンソールとページに出力してみよう
    • JS文法ポイント: console.log での出力、数値演算、文字列連結
      ファイル: basics/01_console_log.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>01 console.log</title>
  </head>
  <body>
    <h1>01 console.log</h1>
    <p>ブラウザの開発者ツールを開いて、コンソールを見てみよう。</p>
    <div id="out"></div>
    <script>
      // ここから JavaScript(JS)のコードです。
      // この <script> タグの中に書いた命令がページを開いたときに実行されます

      // 画面(HTML)にも結果を表示するための小さな関数を作ります。
      // 関数printは「文字列 text を受け取り、<p>タグを作って #out の中に追加する処理です
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }

      // console.log は「開発者ツールのコンソール」に表示します。
      // ブラウザの開発者ツールを開き、Consoleタブで確認しましょう。
      console.log("こんにちは、JavaScript!");
      console.log(1 + 2); // 数字の計算もOK

      // 上と同じ内容を「画面」にも表示してみます。
      print("こんにちは、JavaScript!");
      print("1 + 2 = " + (1 + 2)); // 文字列連結
    </script>
  </body>
  </html>
  • 課題2: 変数(const/let)と再代入の違いを理解しよう
    • JS文法ポイント: const/let と再代入、数値の加算
      ファイル: basics/02_variables.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>02 変数(const/let)</title>
  </head>
  <body>
    <h1>02 変数(const/let)</h1>
    <div id="out"></div>
    <script>
      // 画面表示用の小さな関数(課題1と同じ)
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }

      // 変数(値を入れておく入れ物)について学びます。
      // const は「変わらない値」に使います(再代入できません)。
      const greeting = "Hello";
      // let は「変わる値」に使います(再代入できます)。
      let count = 0;

      // コンソールに現在の値を表示
      console.log(greeting);
      console.log("count:", count);

      // count に 1 を足して、結果をもう一度表示します(再代入の例)
      count = count + 1; // ← let なので再代入OK
      console.log("count:", count);

      // 同じ内容を画面にも表示します
      print(greeting);
      print("count: " + 0);
      print("count: " + 1);
    </script>
  </body>
  </html>
  • 課題3: if(条件分岐)でメッセージを切り替えよう
    • JS文法ポイント: if / else if / else、比較演算子(>=)、ブロック{}
      ファイル: basics/03_if.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>03 if(条件分岐)</title>
  </head>
  <body>
    <h1>03 if(条件分岐)</h1>
    <div id="out"></div>
    <script>
      // 画面に文章を出す小さな関数(課題1と同じ)
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }
      // if(もし〜なら)を使って、条件によって表示を変えてみます。
      const score = 75; // 点数を仮に 75 にしてみます
      // 条件は上から順にチェックされ、最初に当てはまったところが実行されます。
      if (score >= 80) {
        const msg = "すばらしい!"; // 条件1: 80点以上
        console.log(msg);
        print(msg);
      } else if (score >= 60) {
        const msg = "合格!"; // 条件2: 60点以上 80点未満
        console.log(msg);
        print(msg);
      } else {
        const msg = "もう少し!"; // そのほか(60点未満)
        console.log(msg);
        print(msg);
      }
    </script>
  </body>
  </html>
  • 課題4: for(繰り返し)で数値や配列を出力しよう
    • JS文法ポイント: for ループ、配列と length、インデックスアクセス
      ファイル: basics/04_for.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>04 for(繰り返し)</title>
  </head>
  <body>
    <h1>04 for(繰り返し)</h1>
    <div id="out"></div>
    <script>
      // 画面に文字を出す関数(課題1と同じ)
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }
      // for(回数を決めて繰り返す)を使って数字を表示します。
      // i は 0 から始まり、i < 5 のあいだ 1 ずつ増えます0,1,2,3,4)。
      for (let i = 0; i < 5; i++) {
        const line = "i= " + i;
        console.log(line);
        print(line);
      }
      // 配列(複数の値の集まり)を for で順番に取り出して表示します。
      const fruits = ["apple", "banana", "orange"];
      for (let i = 0; i < fruits.length; i++) {
        const line = i + ": " + fruits[i];
        console.log(line);
        print(line);
      }
    </script>
  </body>
  </html>
  • 課題5: 関数(宣言/無名/アロー)を作って呼び出そう
    • JS文法ポイント: 関数宣言・関数式・アロー関数、return と呼び出し
      ファイル: basics/05_function.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>05 function(関数)</title>
  </head>
  <body>
    <h1>05 function(関数)</h1>
    <div id="out"></div>
    <script>
      // 画面に文字を出す関数(課題1と同じ)
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }
      // 関数(処理に名前をつけて、何度でも使えるようにしたもの)を3つの書き方で試します。
      // 1) 関数宣言(いちばん基本の書き方)
      function add(a, b) {
        return a + b; // return は「結果を返す」合図です
      }
      print("1 + 2 = " + add(1, 2)); // add を呼び出して結果を表示
      // 2) 無名関数を変数に代入(関数式)
      const mul = function(a, b) {
        return a * b;
      };
      print("3 * 4 = " + mul(3, 4));
      // 3) アロー関数(短く書ける近年の書き方)
      const pow2 = (x) => x * x; // x を受け取り、x*x を返します
      print("5^2 = " + pow2(5));
    </script>
  </body>
  </html>
  • 課題6: クラスとインスタンスを作ってスコアを加算しよう
    • JS文法ポイント: class と constructor、this、メソッド、new でのインスタンス化
      ファイル: basics/06_class_and_instance.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>06 class と インスタンス</title>
  </head>
  <body>
    <h1>06 class と インスタンス</h1>
    <div id="out"></div>
    <script>
      // 画面に文字を出す関数(課題1と同じ)
      function print(text) {
        const p = document.createElement('p');
        p.textContent = text;
        document.getElementById('out').appendChild(p);
      }
      // クラス(似た性質のデータと処理をひとまとめにした設計図)を作ります。
      // Player は「名前」と「スコア」を持ち、「スコアを増やす」機能を持ちます。
      class Player {
        // constructor は新しく Player を作るときに最初に呼ばれる特別な関数です。
        constructor(name) {
          this.name = name; // this は「今つくっているこの Player 自身」を指します
          this.score = 0;   // 最初のスコアは 0 からスタート
        }
        // メソッド(クラスの中の関数): スコアに points を足します
        addScore(points) {
          this.score += points;
        }
      }
      // 設計図(class)から実体(インスタンス)を作るには new を使います。
      const p = new Player("Taro");
      // メソッドを呼び出してスコアを加算します。
      p.addScore(10);
      // 名前とスコアを画面に表示します。
      print(p.name + " " + p.score);
    </script>
  </body>
  </html>

ライブラリとは?

ソフトウェアを作成するにあたって全てのコードを自分で書くのは大変です。そこで、よく使う機能を「まとめて部品化」したものをライブラリといいます。自分でゼロから作るより、信頼できるライブラリを上手に使うと効率が上がります。

Three.jsとは?

WebGLを簡単に扱うための3Dライブラリです。3Dシーン、カメラ、ライト、メッシュなどを組み合わせて、ブラウザで3D表現ができます。

  • 公式サイト:threejs.org
  • 基本の考え方:
    • Scene(世界)
    • Camera(見る位置/方向)
    • Renderer(画面に描画)
    • Geometry + Material = Mesh(形 + 見た目)

Three.jsでシューティングゲームを作ってみよう

このリポジトリには、完成までを8ステップに分けた「課題ベースの回答JSファイル」が含まれています。各ステップは単体で動き、最終的にはフルゲームになります。

  • ステップ構成(exercises/ 以下)
    1. step01_scene_setup.js: シーン・カメラ・レンダラーの用意とアニメーションループ
    2. step02_player_and_controls.js: プレイヤー(自機)とキーボード入力
    3. step03_bullets.js: 弾の発射と移動
    4. step04_enemies.js: 敵の生成と移動
    5. step05_collision_bullet_enemy.js: 弾と敵の当たり判定
    6. step06_collision_enemy_player.js: 敵とプレイヤーの衝突・ゲームオーバー
    7. step07_score_and_restart.js: スコア・UI・リスタート
    8. step08_polish.js: 仕上げ(ライト・見た目・境界処理など)

実行方法(練習用テンプレート)

exercises/template.html をブラウザで開き、<script type="module" src="..."> のパスをお好みのステップに変更してください。

  • 実行テンプレート: HTMLを開き、scriptのsrcを切り替えて各ステップを試そう
    ファイル: exercises/template.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Three.js Shooting - Exercise Template</title>
    <style>
      html, body { height: 100%; margin: 0; background: #0b1020; color: #e8eefc; font-family: system-ui, sans-serif; }
      #ui { position: fixed; top: 8px; left: 8px; padding: 8px 12px; background: rgba(0,0,0,0.4); border-radius: 8px; }
      a { color: #8ecbff; }
      canvas { display: block; }
    </style>
    <script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
  </head>
  <body>
    <div id="ui">
      <div>base.jsに色々書き加えてみよう!</div>
    </div>
    <!-- ここを書き換えて各ステップを試します-->
    <script src="./base.js"></script>
  </body>
  </html>

最終作品(完成版)

final/index.html を開くと、完成したシューティングゲームが遊べます。ゲーム本体のコードは final/index.js に分離されています。

課題ベースで制作してみよう

  • まず exercises/template.html を開き、空のシーンを表示します。
  • 以降の各ステップでは「追加するコード」を exercises/base.js に追記していきます。
  • 完成例として各ステップのフル版(exercises/stepXX_*.js)も置いてあります。差がわからない時の参考にしてください。

課題Step01: シーン/カメラ/レンダラーを用意し、回る箱を描こう

追加するコード例(base.js に追記):

// 回る箱
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x4cc9f0 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// ライト
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// animate() 内で回転
function animate() {
  requestAnimationFrame(animate);
  // (追加)回転させてみる
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
}
animate();

フルコード:
ファイル: exercises/step01_scene_setup.js

// Step 01: Scene, Camera, Renderer, and basic loop
// 目的: Three.js の最小構成を作って、画面にオブジェクトを表示し続けるループを作成します。
// 新規: シーン/カメラ/レンダラー/ライト/メッシュ/リサイズ対応/アニメーションループ。


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 12);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 回る箱
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x4cc9f0 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// ウィンドウサイズ変更に追従
addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// アニメーションループ
function animate() {
  requestAnimationFrame(animate);
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
}
animate();

課題Step02: 自機メッシュを追加し、キーボードで動かそう

まず回る箱のコードを削除しましょう

追加するコード例(base.js に追記):

// [追加] プレイヤー
const playerGeometry = new THREE.ConeGeometry(0.6, 1.6, 16);
const playerMaterial = new THREE.MeshStandardMaterial({ color: 0x80ffdb });
const player = new THREE.Mesh(playerGeometry, playerMaterial);
player.rotation.x = Math.PI / 2; // point forward (positive Z)
player.position.set(0, 0, 0);
scene.add(player);

// [追加] 入力と移動範囲
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key.toLowerCase()));
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 10 };
const speed = 0.12;

// [追加] 平面グリッド(見やすさのため)
const floor = new THREE.GridHelper(40, 40, 0x223, 0x112);
scene.add(floor);

// [追加] プレイヤー更新
function updatePlayer() {
  let dx = 0;
  let dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;

  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}


function animate() {
  requestAnimationFrame(animate);

  // (追加)ここでPlayer更新の関数を呼び出してみよう
  updatePlayer();
  renderer.render(scene, camera);
}
animate();

フルコード:
ファイル: exercises/step02_player_and_controls.js

// Step 02: Player ship and keyboard controls
// 目的: 自機(プレイヤー)を追加し、キーボード操作で移動できるようにします。
// 変更点(Step01からの追加):
// - [追加] プレイヤーのメッシュ(円錐)
// - [追加] キーボード入力の管理(keydown/keyup)
// - [追加] プレイヤー移動と移動範囲(bounds)
// - [追加] 参考のグリッド(床)

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// [追加] プレイヤー
const playerGeometry = new THREE.ConeGeometry(0.6, 1.6, 16);
const playerMaterial = new THREE.MeshStandardMaterial({ color: 0x80ffdb });
const player = new THREE.Mesh(playerGeometry, playerMaterial);
player.rotation.x = Math.PI / 2; // point forward (positive Z)
player.position.set(0, 0, 0);
scene.add(player);

// [追加] 平面グリッド(見やすさのため)
const floor = new THREE.GridHelper(40, 40, 0x223, 0x112);
scene.add(floor);

// [追加] 入力と移動範囲
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key.toLowerCase()));
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 10 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// [追加] プレイヤー更新
function updatePlayer() {
  let dx = 0;
  let dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;

  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

function animate() {
  requestAnimationFrame(animate);
  updatePlayer();
  renderer.render(scene, camera);
}
animate();

課題Step03: スペースで弾を発射し、前進・消去しよう

追加するコード例(base.js に追記):

// [追加] 弾
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// [追加] 発射
addEventListener('keydown', e => { if (e.code === 'Space') spawnBullet(); });
function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

// [追加] 弾の更新
function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 5) { scene.remove(b); bullets.splice(i, 1); }
  }
}

// animate() 内で呼び出し
// updateBullets(); を追加

フルコード:
ファイル: exercises/step03_bullets.js

// Step 03: Bullets - spawn on Space, move forward, remove off-bounds
// 目的: スペースキーで弾を発射し、前方へ進め、画面外で削除します。
// 変更点(Step02からの追加):
// - [追加] 弾の配列と生成・移動ロジック
// - [追加] SpaceキーでspawnBullet()
// - [追加] 画面外(手前方向)に出た弾の削除


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// Player
const player = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 16), new THREE.MeshStandardMaterial({ color: 0x80ffdb }));
player.rotation.x = Math.PI / 2;
scene.add(player);

// [追加] Bullets
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// [変更] 入力: Spaceで弾生成を追加
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space") spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 12 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// [追加] 弾をプレイヤー位置から生成
function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

// [追加] 弾の移動と寿命管理
function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 5) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function animate() {
  requestAnimationFrame(animate);
  updatePlayer();
  updateBullets();
  renderer.render(scene, camera);
}
animate();

課題Step04: 敵を定期的にスポーンさせ、手前へ移動させよう

追加するコード例(base.js に追記):

// [追加] 敵
const enemies = [];
const enemySpeed = 0.08;
const enemyGeometry = new THREE.IcosahedronGeometry(0.7, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });
let enemySpawnTimer = 0;
const enemySpawnInterval = 45;

function spawnEnemy() {
  const e = new THREE.Mesh(enemyGeometry, enemyMaterial);
  e.position.set(THREE.MathUtils.randFloat(-bounds.x, bounds.x), 0, -bounds.z - 6);
  scene.add(e); enemies.push(e);
}

function updateEnemies() {
  if (++enemySpawnTimer >= enemySpawnInterval) { enemySpawnTimer = 0; spawnEnemy(); }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i]; e.position.z += enemySpeed; e.rotation.y += 0.01;
    if (e.position.z > bounds.z + 6) { scene.remove(e); enemies.splice(i, 1); }
  }
}

// animate() 内で呼び出し
// updateEnemies(); を追加

フルコード:
ファイル: exercises/step04_enemies.js

// Step 04: Enemies - spawn periodically and move toward the player
// 目的: 一定間隔で敵を出現させ、手前方向へ移動させます。
// 変更点(Step03からの追加):
// - [追加] 敵配列と生成(spawnEnemy)
// - [追加] 敵の移動・寿命管理
// - [追加] スポーンタイマー(フレームベース)


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// Player
const player = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 16), new THREE.MeshStandardMaterial({ color: 0x80ffdb }));
player.rotation.x = Math.PI / 2;
scene.add(player);

// Bullets
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// [追加] Enemies
const enemies = [];
const enemySpeed = 0.08;
const enemyGeometry = new THREE.IcosahedronGeometry(0.7, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });
let enemySpawnTimer = 0;
const enemySpawnInterval = 45; // frames

// Input
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space") spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 12 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

// [追加] 画面奥からランダムXでスポーン
function spawnEnemy() {
  const enemy = new THREE.Mesh(enemyGeometry, enemyMaterial);
  const x = THREE.MathUtils.randFloat(-bounds.x, bounds.x);
  const z = -bounds.z - 6; // off-screen in front
  enemy.position.set(x, 0, z);
  scene.add(enemy);
  enemies.push(enemy);
}

// [追加] 敵の移動とスポーン管理
function updateEnemies() {
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnInterval) {
    enemySpawnTimer = 0;
    spawnEnemy();
  }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.position.z += enemySpeed;
    e.rotation.y += 0.01;
    if (e.position.z > bounds.z + 6) {
      scene.remove(e);
      enemies.splice(i, 1);
    }
  }
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 8) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function animate() {
  requestAnimationFrame(animate);
  updatePlayer();
  updateBullets();
  updateEnemies();
  renderer.render(scene, camera);
}
animate();

課題Step05: 弾と敵の当たり判定を実装して消えるようにしよう

追加するコード例(base.js に追記):

function handleCollisions() {
  const bulletRadius = 0.15, enemyRadius = 0.7;
  const hitDistSq = (bulletRadius + enemyRadius) ** 2;
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    for (let j = enemies.length - 1; j >= 0; j--) {
      const e = enemies[j];
      const dx = b.position.x - e.position.x; const dz = b.position.z - e.position.z;
      if (dx*dx + dz*dz <= hitDistSq) { scene.remove(b); bullets.splice(i,1); scene.remove(e); enemies.splice(j,1); break; }
    }
  }
}

// animate() 内で呼び出し
// handleCollisions(); を追加

フルコード:
ファイル: exercises/step05_collision_bullet_enemy.js

// Step 05: Bullet-Enemy collisions - remove both on hit
// 目的: 弾と敵の当たり判定を行い、衝突時に両方を削除します。
// 変更点(Step04からの追加):
// - [追加] handleCollisions(): 弾と敵の距離を測って判定
// - [追加] 半径の近似での円判定(2D平面の距離²で比較)


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// Player
const player = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 16), new THREE.MeshStandardMaterial({ color: 0x80ffdb }));
player.rotation.x = Math.PI / 2;
scene.add(player);

// Bullets
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// Enemies
const enemies = [];
const enemySpeed = 0.08;
const enemyGeometry = new THREE.IcosahedronGeometry(0.7, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });
let enemySpawnTimer = 0;
const enemySpawnInterval = 45;

// Input
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space") spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 12 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

function spawnEnemy() {
  const enemy = new THREE.Mesh(enemyGeometry, enemyMaterial);
  const x = THREE.MathUtils.randFloat(-bounds.x, bounds.x);
  const z = -bounds.z - 6;
  enemy.position.set(x, 0, z);
  scene.add(enemy);
  enemies.push(enemy);
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 8) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function updateEnemies() {
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnInterval) {
    enemySpawnTimer = 0;
    spawnEnemy();
  }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.position.z += enemySpeed;
    e.rotation.y += 0.01;
    if (e.position.z > bounds.z + 6) {
      scene.remove(e);
      enemies.splice(i, 1);
    }
  }
}

// [追加] 弾-敵の衝突判定
function handleCollisions() {
  const bulletRadius = 0.15;
  const enemyRadius = 0.7;
  const hitDistSq = (bulletRadius + enemyRadius) ** 2;

  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    for (let j = enemies.length - 1; j >= 0; j--) {
      const e = enemies[j];
      const dx = b.position.x - e.position.x;
      const dz = b.position.z - e.position.z;
      const distSq = dx * dx + dz * dz;
      if (distSq <= hitDistSq) {
        scene.remove(b);
        bullets.splice(i, 1);
        scene.remove(e);
        enemies.splice(j, 1);
        break;
      }
    }
  }
}

function animate() {
  requestAnimationFrame(animate);
  updatePlayer();
  updateBullets();
  updateEnemies();
  handleCollisions();
  renderer.render(scene, camera);
}
animate();

課題Step06: 敵と自機の衝突でゲームオーバー表示をしよう

追加するコード例(base.js に追記):

// [追加] UI とゲームオーバー
const ui = document.createElement('div');
Object.assign(ui.style, { position:'fixed', top:'12px', left:'12px', padding:'8px 12px', background:'rgba(0,0,0,0.45)', color:'#e8eefc', borderRadius:'8px', fontFamily:'system-ui,sans-serif' });
document.body.appendChild(ui);
let isGameOver = false;

function handleEnemyPlayerCollisions() {
  const playerRadius = 0.6, enemyRadius = 0.7, hitDistSq = (playerRadius + enemyRadius) ** 2;
  for (const e of enemies) {
    const dx = player.position.x - e.position.x, dz = player.position.z - e.position.z;
    if (dx*dx + dz*dz <= hitDistSq) { isGameOver = true; ui.textContent = 'GAME OVER - Enterでリスタート'; break; }
  }
}

addEventListener('keydown', e => { if (isGameOver && e.key.toLowerCase() === 'enter') location.reload(); });

// animate():
// if (!isGameOver) { updatePlayer(); updateBullets(); updateEnemies(); handleBulletEnemyCollisions(); handleEnemyPlayerCollisions(); }

フルコード:
ファイル: exercises/step06_collision_enemy_player.js

// Step 06: Enemy-Player collision and Game Over state
// 目的: 敵とプレイヤーの衝突でゲームオーバー状態に遷移し、UIで表示します。
// 変更点(Step05からの追加):
// - [追加] UI表示(DOM要素)
// - [追加] handleEnemyPlayerCollisions(): プレイヤーと敵の当たり判定
// - [追加] isGameOver の導入とEnterでリスタート


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// [追加] UI
const ui = document.createElement("div");
ui.style.position = "fixed";
ui.style.top = "12px";
ui.style.left = "12px";
ui.style.padding = "8px 12px";
ui.style.background = "rgba(0,0,0,0.45)";
ui.style.color = "#e8eefc";
ui.style.borderRadius = "8px";
ui.style.fontFamily = "system-ui, sans-serif";
document.body.appendChild(ui);

let isGameOver = false;

// Player
const player = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 16), new THREE.MeshStandardMaterial({ color: 0x80ffdb }));
player.rotation.x = Math.PI / 2;
scene.add(player);

// Bullets
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// Enemies
const enemies = [];
const enemySpeed = 0.08;
const enemyGeometry = new THREE.IcosahedronGeometry(0.7, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });
let enemySpawnTimer = 0;
const enemySpawnInterval = 45;

// Input
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space" && !isGameOver) spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 12 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

function spawnEnemy() {
  const enemy = new THREE.Mesh(enemyGeometry, enemyMaterial);
  const x = THREE.MathUtils.randFloat(-bounds.x, bounds.x);
  const z = -bounds.z - 6;
  enemy.position.set(x, 0, z);
  scene.add(enemy);
  enemies.push(enemy);
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 8) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function updateEnemies() {
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnInterval) {
    enemySpawnTimer = 0;
    spawnEnemy();
  }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.position.z += enemySpeed;
    e.rotation.y += 0.01;
    if (e.position.z > bounds.z + 6) {
      scene.remove(e);
      enemies.splice(i, 1);
    }
  }
}

// [追加] 敵-プレイヤーの衝突判定
function handleBulletEnemyCollisions() {
  const bulletRadius = 0.15;
  const enemyRadius = 0.7;
  const hitDistSq = (bulletRadius + enemyRadius) ** 2;
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    for (let j = enemies.length - 1; j >= 0; j--) {
      const e = enemies[j];
      const dx = b.position.x - e.position.x;
      const dz = b.position.z - e.position.z;
      const distSq = dx * dx + dz * dz;
      if (distSq <= hitDistSq) {
        scene.remove(b);
        bullets.splice(i, 1);
        scene.remove(e);
        enemies.splice(j, 1);
        break;
      }
    }
  }
}

// [追加] 敵-プレイヤーの衝突判定
function handleEnemyPlayerCollisions() {
  const playerRadius = 0.6;
  const enemyRadius = 0.7;
  const hitDistSq = (playerRadius + enemyRadius) ** 2;
  for (let j = enemies.length - 1; j >= 0; j--) {
    const e = enemies[j];
    const dx = player.position.x - e.position.x;
    const dz = player.position.z - e.position.z;
    const distSq = dx * dx + dz * dz;
    if (distSq <= hitDistSq) {
      isGameOver = true;
      ui.textContent = "GAME OVER - Enterでリスタート";
      break;
    }
  }
}

addEventListener("keydown", (e) => {
  if (isGameOver && e.key.toLowerCase() === "enter") {
    location.reload();
  }
});

function animate() {
  requestAnimationFrame(animate);
  if (!isGameOver) {
    updatePlayer();
    updateBullets();
    updateEnemies();
    handleBulletEnemyCollisions();
    handleEnemyPlayerCollisions();
    ui.textContent = "敵に当たらないよう避けつつスペースで攻撃";
  }
  renderer.render(scene, camera);
}
animate();

課題Step07: スコア表示とEnterでのリスタートを追加しよう

追加するコード例(base.js に追記):

let score = 0; let enemySpawnInterval = 45;

// [追加] 撃破時の加点(弾-敵の衝突内)
// score += 10;

// [追加] だんだん難しく(updateEnemies内)
// if (enemySpawnInterval > 20) enemySpawnInterval--;

function renderUI() {
  ui.style.whiteSpace = 'pre';
  ui.textContent = isGameOver ? `SCORE: ${score}\nGAME OVER - Enterでリスタート` : `SCORE: ${score}\nSpaceで発射 / 矢印/WASDで移動`;
}

// [追加] animate() の最後で renderUI(); を呼ぶ

フルコード:
ファイル: exercises/step07_score_and_restart.js

// Step 07: Score, UI, and Restart
// 目的: スコア加算、UI表示、Enterでリスタートできるようにします。
// 変更点(Step06からの追加):
// - [追加] score 変数と加点(敵撃破時)
// - [追加] UI表示の強化(スコア表示)
// - [追加] 難易度の段階的上昇(スポーン間隔短縮)


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);

// UI
const ui = document.createElement("div");
ui.style.position = "fixed";
ui.style.top = "12px";
ui.style.left = "12px";
ui.style.padding = "8px 12px";
ui.style.background = "rgba(0,0,0,0.45)";
ui.style.color = "#e8eefc";
ui.style.borderRadius = "8px";
ui.style.fontFamily = "system-ui, sans-serif";
ui.style.whiteSpace = "pre";
document.body.appendChild(ui);

let isGameOver = false;
let score = 0;

// Player
const player = new THREE.Mesh(new THREE.ConeGeometry(0.6, 1.6, 16), new THREE.MeshStandardMaterial({ color: 0x80ffdb }));
player.rotation.x = Math.PI / 2;
scene.add(player);

// Bullets
const bullets = [];
const bulletSpeed = 0.6;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 8);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x332200 });

// Enemies
const enemies = [];
const enemySpeed = 0.08;
const enemyGeometry = new THREE.IcosahedronGeometry(0.7, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });
let enemySpawnTimer = 0;
let enemySpawnInterval = 45;

// Input
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space" && !isGameOver) spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 10, z: 12 };
const speed = 0.12;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  scene.add(b);
  bullets.push(b);
}

function spawnEnemy() {
  const enemy = new THREE.Mesh(enemyGeometry, enemyMaterial);
  const x = THREE.MathUtils.randFloat(-bounds.x, bounds.x);
  const z = -bounds.z - 6;
  enemy.position.set(x, 0, z);
  scene.add(enemy);
  enemies.push(enemy);
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  player.position.x = Math.max(-bounds.x, Math.min(bounds.x, player.position.x + dx * speed));
  player.position.z = Math.max(-bounds.z, Math.min(bounds.z, player.position.z + dz * speed));
}

function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 8) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function updateEnemies() {
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnInterval) {
    enemySpawnTimer = 0;
    spawnEnemy();
    // 少しずつ難しく
    if (enemySpawnInterval > 20) enemySpawnInterval--;
  }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.position.z += enemySpeed;
    e.rotation.y += 0.01;
    if (e.position.z > bounds.z + 6) {
      scene.remove(e);
      enemies.splice(i, 1);
    }
  }
}

// [追加] UIの描画
function handleBulletEnemyCollisions() {
  const bulletRadius = 0.15;
  const enemyRadius = 0.7;
  const hitDistSq = (bulletRadius + enemyRadius) ** 2;
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    for (let j = enemies.length - 1; j >= 0; j--) {
      const e = enemies[j];
      const dx = b.position.x - e.position.x;
      const dz = b.position.z - e.position.z;
      const distSq = dx * dx + dz * dz;
      if (distSq <= hitDistSq) {
        scene.remove(b);
        bullets.splice(i, 1);
        scene.remove(e);
        enemies.splice(j, 1);
        score += 10;
        break;
      }
    }
  }
}

function handleEnemyPlayerCollisions() {
  const playerRadius = 0.6;
  const enemyRadius = 0.7;
  const hitDistSq = (playerRadius + enemyRadius) ** 2;
  for (let j = enemies.length - 1; j >= 0; j--) {
    const e = enemies[j];
    const dx = player.position.x - e.position.x;
    const dz = player.position.z - e.position.z;
    const distSq = dx * dx + dz * dz;
    if (distSq <= hitDistSq) {
      isGameOver = true;
      break;
    }
  }
}

function renderUI() {
  if (isGameOver) {
    ui.textContent = `SCORE: ${score}\nGAME OVER - Enterでリスタート`;
  } else {
    ui.textContent = `SCORE: ${score}\nSpaceで発射 / 矢印/WASDで移動`;
  }
}

addEventListener("keydown", (e) => {
  if (isGameOver && e.key.toLowerCase() === "enter") {
    location.reload();
  }
});

function animate() {
  requestAnimationFrame(animate);
  if (!isGameOver) {
    updatePlayer();
    updateBullets();
    updateEnemies();
    handleBulletEnemyCollisions();
    handleEnemyPlayerCollisions();
  }
  renderUI();
  renderer.render(scene, camera);
}
animate();

課題Step08: ライト/影/星空など見た目を整えて仕上げよう

追加するコード例(base.js に追記):

// [追加] ライティング
const ambient = new THREE.AmbientLight(0xffffff, 0.25); scene.add(ambient);
const dir = new THREE.DirectionalLight(0xffffff, 1.0); dir.position.set(5,10,7); dir.castShadow = true; scene.add(dir);

// [追加] 星空
const starGeo = new THREE.BufferGeometry();
const starPositions = new Float32Array(800*3);
for (let i=0;i<800;i++){ starPositions[i*3]=THREE.MathUtils.randFloatSpread(80); starPositions[i*3+1]=THREE.MathUtils.randFloatSpread(30)-10; starPositions[i*3+2]=THREE.MathUtils.randFloatSpread(80)-20; }
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions,3));
const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color:0x88aaff, size:0.08 }));
scene.add(stars);

// [追加] 影を受ける地面
const ground = new THREE.Mesh(new THREE.PlaneGeometry(80,40), new THREE.MeshStandardMaterial({ color:0x0b1020, roughness:1 }));
ground.rotation.x = -Math.PI/2; ground.position.y = -1.2; ground.receiveShadow = true; scene.add(ground);

// [追加] animate() 内で星をゆっくり回す
// stars.rotation.z += 0.0008;

フルコード:
ファイル: exercises/step08_polish.js

// Step 08: Polish - lights, starfield, boundaries, nicer visuals
// 目的: 見た目と体験を磨きます。
// 変更点(Step07からの追加):
// - [追加] 環境光と平行光、影(castShadow/receiveShadow)
// - [追加] 星の粒子(Points)による背景
// - [追加] 地面シェードと速度/境界の微調整


const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050a18);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 8, 16);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

// [追加] Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
scene.add(ambient);
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(5, 10, 7);
dir.castShadow = true;
scene.add(dir);

// [追加] 星空のパーティクル
const starGeo = new THREE.BufferGeometry();
const starCount = 800;
const starPositions = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
  starPositions[i * 3 + 0] = THREE.MathUtils.randFloatSpread(80);
  starPositions[i * 3 + 1] = THREE.MathUtils.randFloatSpread(30) - 10;
  starPositions[i * 3 + 2] = THREE.MathUtils.randFloatSpread(80) - 20;
}
starGeo.setAttribute("position", new THREE.BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({ color: 0x88aaff, size: 0.08 });
const stars = new THREE.Points(starGeo, starMat);
scene.add(stars);

// [継続] UI(スコア/操作説明)
const ui = document.createElement("div");
ui.style.position = "fixed";
ui.style.top = "12px";
ui.style.left = "12px";
ui.style.padding = "8px 12px";
ui.style.background = "rgba(0,0,0,0.45)";
ui.style.color = "#e8eefc";
ui.style.borderRadius = "8px";
ui.style.fontFamily = "system-ui, sans-serif";
ui.style.whiteSpace = "pre";
document.body.appendChild(ui);

let isGameOver = false;
let score = 0;

// Player
const player = new THREE.Mesh(
  new THREE.ConeGeometry(0.6, 1.6, 24),
  new THREE.MeshStandardMaterial({ color: 0x80ffdb, roughness: 0.4, metalness: 0.2 })
);
player.rotation.x = Math.PI / 2;
player.castShadow = true;
scene.add(player);

// Shadow ground
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(80, 40),
  new THREE.MeshStandardMaterial({ color: 0x0b1020, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.2;
ground.receiveShadow = true;
scene.add(ground);

// Bullets
const bullets = [];
const bulletSpeed = 0.7;
const bulletGeometry = new THREE.CylinderGeometry(0.08, 0.08, 0.8, 12);
const bulletMaterial = new THREE.MeshStandardMaterial({ color: 0xffe066, emissive: 0x664400 });

// Enemies
const enemies = [];
const enemySpeed = 0.1;
const enemyGeometry = new THREE.DodecahedronGeometry(0.8, 0);
const enemyMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b, roughness: 0.5 });
let enemySpawnTimer = 0;
let enemySpawnInterval = 40;

// Input
const keys = new Set();
addEventListener("keydown", (e) => {
  keys.add(e.key.toLowerCase());
  if (e.code === "Space" && !isGameOver) spawnBullet();
});
addEventListener("keyup", (e) => keys.delete(e.key.toLowerCase()));

const bounds = { x: 11, z: 11 };
const speed = 0.14;

addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function spawnBullet() {
  const b = new THREE.Mesh(bulletGeometry, bulletMaterial);
  b.rotation.x = Math.PI / 2;
  b.position.copy(player.position);
  b.castShadow = true;
  scene.add(b);
  bullets.push(b);
}

function spawnEnemy() {
  const enemy = new THREE.Mesh(enemyGeometry, enemyMaterial);
  enemy.castShadow = true;
  const x = THREE.MathUtils.randFloat(-bounds.x, bounds.x);
  const z = -bounds.z - 7;
  enemy.position.set(x, 0, z);
  scene.add(enemy);
  enemies.push(enemy);
}

function updatePlayer() {
  let dx = 0, dz = 0;
  if (keys.has("arrowleft") || keys.has("a")) dx -= 1;
  if (keys.has("arrowright") || keys.has("d")) dx += 1;
  if (keys.has("arrowup") || keys.has("w")) dz -= 1;
  if (keys.has("arrowdown") || keys.has("s")) dz += 1;
  const nx = THREE.MathUtils.clamp(player.position.x + dx * speed, -bounds.x, bounds.x);
  const nz = THREE.MathUtils.clamp(player.position.z + dz * speed, -bounds.z, bounds.z);
  player.position.set(nx, 0, nz);
}

function updateBullets() {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.position.z -= bulletSpeed;
    if (b.position.z < -bounds.z - 10) {
      scene.remove(b);
      bullets.splice(i, 1);
    }
  }
}

function updateEnemies() {
  enemySpawnTimer++;
  if (enemySpawnTimer >= enemySpawnInterval) {
    enemySpawnTimer = 0;
    spawnEnemy();
    if (enemySpawnInterval > 18) enemySpawnInterval--;
  }
  for (let i = enemies.length - 1; i >= 0; i--) {
    const e = enemies[i];
    e.position.z += enemySpeed;
    e.rotation.y += 0.015;
    if (e.position.z > bounds.z + 8) {
      scene.remove(e);
      enemies.splice(i, 1);
    }
  }
}

function handleBulletEnemyCollisions() {
  const bulletRadius = 0.15;
  const enemyRadius = 0.8;
  const hitDistSq = (bulletRadius + enemyRadius) ** 2;
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    for (let j = enemies.length - 1; j >= 0; j--) {
      const e = enemies[j];
      const dx = b.position.x - e.position.x;
      const dz = b.position.z - e.position.z;
      const distSq = dx * dx + dz * dz;
      if (distSq <= hitDistSq) {
        scene.remove(b);
        bullets.splice(i, 1);
        scene.remove(e);
        enemies.splice(j, 1);
        score += 10;
        break;
      }
    }
  }
}

function handleEnemyPlayerCollisions() {
  const playerRadius = 0.6;
  const enemyRadius = 0.8;
  const hitDistSq = (playerRadius + enemyRadius) ** 2;
  for (let j = enemies.length - 1; j >= 0; j--) {
    const e = enemies[j];
    const dx = player.position.x - e.position.x;
    const dz = player.position.z - e.position.z;
    const distSq = dx * dx + dz * dz;
    if (distSq <= hitDistSq) {
      isGameOver = true;
      break;
    }
  }
}

addEventListener("keydown", (e) => {
  if (isGameOver && e.key.toLowerCase() === "enter") {
    location.reload();
  }
});

function renderUI() {
  if (isGameOver) {
    ui.textContent = `SCORE: ${score}\nGAME OVER - Enterでリスタート`;
  } else {
    ui.textContent = `SCORE: ${score}\nSpaceで発射 / 矢印/WASDで移動`;
  }
}

function animate() {
  requestAnimationFrame(animate);
  if (!isGameOver) {
    updatePlayer();
    updateBullets();
    updateEnemies();
    handleBulletEnemyCollisions();
    handleEnemyPlayerCollisions();
    stars.rotation.z += 0.0008;
  }
  renderUI();
  renderer.render(scene, camera);
}
animate();

補足:困ったときは

  • 画面が真っ黒:カメラの位置、ライト、レンダラーサイズ、アニメーションループを再確認
  • キーボードが効かない:keydown/keyup のイベント登録とフォーカス(ブラウザのどこをクリックしたか)を確認
  • 当たり判定が変:座標系とスケール(範囲定数)を見直す

小さく直して、コンソール(console.log)で確認しながら進めましょう!

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?