この記事では、プログラミングを初めて学ぶ方向けに、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)
- 管理者としてPowerShell(またはWindows Terminal)を開く
- 次のコマンドを実行
wsl --install
- 指示に従って再起動し、ディストリビューション(通常はUbuntu)を初期設定します(ユーザー名・パスワード設定など)。
詳しくは公式ドキュメントを参照してください:Microsoft Docs - WSL
Homebrewのインストール(macOS)
HomebrewはmacOS向けのパッケージマネージャーです。開発ツールのインストールが簡単になります。
- ターミナルを開く
- 次をそのまま貼り付けて実行
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- 表示される指示に従って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アカウントの作り方
- 公式サイトへアクセス:GitHub
- Sign up(無料登録)からユーザー名・メール・パスワードを入力
- メール認証を済ませ、ログインします。
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
- JS文法ポイント: console.log での出力、数値演算、文字列連結
<!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
- JS文法ポイント: const/let と再代入、数値の加算
<!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
- JS文法ポイント: if / else if / else、比較演算子(>=)、ブロック{}
<!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
- JS文法ポイント: for ループ、配列と length、インデックスアクセス
<!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
- JS文法ポイント: 関数宣言・関数式・アロー関数、return と呼び出し
<!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
- JS文法ポイント: class と constructor、this、メソッド、new でのインスタンス化
<!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/
以下)-
step01_scene_setup.js
: シーン・カメラ・レンダラーの用意とアニメーションループ -
step02_player_and_controls.js
: プレイヤー(自機)とキーボード入力 -
step03_bullets.js
: 弾の発射と移動 -
step04_enemies.js
: 敵の生成と移動 -
step05_collision_bullet_enemy.js
: 弾と敵の当たり判定 -
step06_collision_enemy_player.js
: 敵とプレイヤーの衝突・ゲームオーバー -
step07_score_and_restart.js
: スコア・UI・リスタート -
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
)で確認しながら進めましょう!