はじめに
今までの3次元物体の回転の記事や2次元図形の回転の記事はヘロンの数学ちゃんねるの動画を元に作成していました。とても分かりやすくて、おすすめのチャンネルです!今回はヘロンの数学ちゃんねるで紹介されていたFPSの描画を実装していきたいと思います。今まではp5.jsを使っていましたが、今回は敢えてjavaScriptで書いていこうと思います。
プログラムの実装
全てを解説するのは長くなるので、要所を抑えていきます。
まずは、プレイヤーを描画します。fpsゲームではよくあるwasdで上左下右の動きを再現します。
そして、右矢印と左矢印でプレイヤーの向きを変えます。
また、壁との当たり判定の部分も入っています。checkCollisionの部分で壁との当たり判定がなければ、新しい位置を更新するようにしています。
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const PI = Math.PI;
const cos = Math.cos;
const sin = Math.sin;
// ===================== メイン描画 =====================
function draw() {
const { player, level } = game;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// === 入力処理 ===
let nextX = player.pos.x;
let nextY = player.pos.y;
// ←→ で回転
if (keys["arrowleft"]) player.angle -= 0.05;
if (keys["arrowright"]) player.angle += 0.05;
// 移動方向ベクトル
const forwardX = cos(player.angle) * player.speed;
const forwardY = sin(player.angle) * player.speed;
// 左右移動(ストレーフ)
const rightX = cos(player.angle + Math.PI / 2) * player.speed;
const rightY = sin(player.angle + Math.PI / 2) * player.speed;
if (keys["w"]) {
nextX += forwardX;
nextY += forwardY;
}
if (keys["s"]) {
nextX -= forwardX;
nextY -= forwardY;
}
if (keys["a"]) {
nextX -= rightX;
nextY -= rightY;
}
if (keys["d"]) {
nextX += rightX;
nextY += rightY;
}
if (!checkCollision(nextX, nextY)) {
player.pos.x = nextX;
player.pos.y = nextY;
}
// Draw player
ctx.fillStyle = "yellow";
ctx.beginPath();
ctx.arc(player.pos.x, player.pos.y, 5, 0, Math.PI*2);
ctx.fill();
requestAnimationFrame(draw);
}
draw();
addEventListenerでキーボードの入力を認識します。
// ===================== 入力処理 =====================
const keys = {};
window.addEventListener("keydown", e => keys[e.key.toLowerCase()] = true);
window.addEventListener("keyup", e => keys[e.key.toLowerCase()] = false);
window.addEventListener("keydown", e => {
if (e.key === "ArrowLeft") keys["arrowleft"] = true;
if (e.key === "ArrowRight") keys["arrowright"] = true;
});
window.addEventListener("keyup", e => {
if (e.key === "ArrowLeft") keys["arrowleft"] = false;
if (e.key === "ArrowRight") keys["arrowright"] = false;
});
以下、プレイヤーと壁との衝突判定
高校の数学で習った点と線分の距離の公式を使っています。点(プレイヤー)と線分(壁)の距離がプレイヤーの半径より小さくなったら衝突というわけですね。
// ===================== 衝突判定 =====================
function checkCollision(nextX, nextY) {
const r = game.player.radius;
for (const wall of game.level.walls) {
const x1 = wall.begin.x, y1 = wall.begin.y, x2 = wall.end.x, y2 = wall.end.y;
const nearestX = Math.max(Math.min(nextX, Math.max(x1, x2)), Math.min(x1, x2));
const nearestY = Math.max(Math.min(nextY, Math.max(y1, y2)), Math.min(y1, y2));
const dx = nextX - nearestX;
const dy = nextY - nearestY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < r) return true;
}
return false;
}
次に、プレイヤーの視界を表現します。プレイヤーから伸びていく線分を視界とします。
// 光線描画
function drawRays(ctx, player, walls) {
const fov = 0; // 視野角
const beamCount = 1; // 光線の本数
const maxDist = 200; // 光線の最大距離
ctx.strokeStyle = "yellow";
ctx.lineWidth = 1;
for (let i = 0; i < beamCount; i++) {
const angle = player.angle - fov / 2 + (fov / beamCount) * i;
const dir = new Vec2(Math.cos(angle), Math.sin(angle));
const ray = new Ray2(player.pos.copy(), dir.mult(maxDist));
// すべての壁との交差点を調べ、最も近い点を採用
let nearestHit = null;
let nearestDist = Infinity;
for (let wall of walls) {
const hit = ray.intersection(wall);
if (hit) {
const dist = hit.sub(ray.begin).mag();
if (dist < nearestDist) {
nearestDist = dist;
nearestHit = hit;
}
}
}
// 光線を描画
ctx.beginPath();
ctx.moveTo(player.pos.x, player.pos.y);
if (nearestHit) {
ctx.lineTo(nearestHit.x, nearestHit.y); // 壁で止める
ctx.stroke();
// 衝突点を描画(小さい点)
ctx.fillStyle = "orange";
ctx.beginPath();
ctx.arc(nearestHit.x, nearestHit.y, 2, 0, Math.PI * 2);
ctx.fill();
} else {
// 壁に当たらなかった場合は最大距離まで
const end = ray.end;
ctx.lineTo(end.x, end.y);
ctx.stroke();
}
}
}
また、メイン描画のところにdrawRaysを呼び出せすと視界が表示されます。game.level.wallsは外側の壁を表現しています。
// ===================== メイン描画 =====================
function draw() {
// ****** 省略 ******
// Draw player
ctx.fillStyle = "yellow";
ctx.beginPath();
ctx.arc(player.pos.x, player.pos.y, 5, 0, Math.PI*2);
ctx.fill();
// Draw rays (可視化)
drawRays(ctx, player, game.level.walls);
requestAnimationFrame(draw);
}
Ray2インスタンスやVec2クラスを用いて線を表現します。特にintersectionの部分はプレイヤーの視線と壁との交差判定をするもので、プレイヤー自身の衝突判定でないことに注意します。
// ===================== Vec2 / Ray2 =====================
class Vec2 {
constructor(x, y) { this.x = x; this.y = y; }
add(v) { return new Vec2(this.x + v.x, this.y + v.y); }
sub(v) { return new Vec2(this.x - v.x, this.y - v.y); }
mult(s) { return new Vec2(this.x * s, this.y * s); }
mag() { return Math.sqrt(this.x ** 2 + this.y ** 2); }
copy() { return new Vec2(this.x, this.y); }
}
// ========= Ray2 class =========
class Ray2 {
constructor(pos, way) {
this.pos = pos;
this.way = way;
}
static withPoints(begin, end) {
return new Ray2(begin, end.sub(begin));
}
get begin() { return this.pos; }
get end() { return this.pos.add(this.way); }
// 壁との交差判定
intersection(r2) {
let r1 = this;
if (Math.abs(r1.way.x) < 0.01) r1.way.x = 0.01;
if (Math.abs(r2.way.x) < 0.01) r2.way.x = 0.01;
let t1 = r1.way.y / r1.way.x;
let t2 = r2.way.y / r2.way.x;
let x1 = r1.pos.x;
let x2 = r2.pos.x;
let y1 = r1.pos.y;
let y2 = r2.pos.y;
let sx = (t1*x1 - t2*x2 - y1 + y2) / (t1 - t2);
let sy = t1 * (sx - x1) + y1;
if (
sx > Math.min(r1.begin.x, r1.end.x) &&
sx < Math.max(r1.begin.x, r1.end.x) &&
sx > Math.min(r2.begin.x, r2.end.x) &&
sx < Math.max(r2.begin.x, r2.end.x)
){
return new Vec2(sx, sy);
} else {
return null;
}
}
}
⚪︎または×で障害物とします。
// ===================== Level =====================
class Level {
constructor() {
this.walls = [];
this.tilemap = '';
this.tileSize = 35;
this.mapWidth = 0;
this.mapHeight = 0;
}
tileAt(x, y) {
if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) return '.';
return this.tilemap[this.mapWidth * y + x];
}
addWorldEdges() {
let s = this.tileSize;
let w = this.mapWidth;
let h = this.mapHeight;
this.walls.push(new Ray2(new Vec2(0, 0), new Vec2(s * w, 0)));
this.walls.push(new Ray2(new Vec2(0, 0), new Vec2(0, s * h)));
this.walls.push(new Ray2(new Vec2(s * w, s * h), new Vec2(-s * w, 0)));
this.walls.push(new Ray2(new Vec2(s * w, s * h), new Vec2(0, -s * h)));
}
addTilemap(tilemap, width, height, size) {
this.tilemap = tilemap;
this.mapWidth = width;
this.mapHeight = height;
this.tileSize = size;
let s = size;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let tile = this.tileAt(x, y);
if (tile === 'O' || tile === 'X') {
this.walls.push(new Ray2(new Vec2(s * x, s * y), new Vec2(s, 0)));
this.walls.push(new Ray2(new Vec2(s * x, s * y), new Vec2(0, s)));
if (this.tileAt(x, y + 1) === '.')
this.walls.push(new Ray2(new Vec2(s * x, s * y + s), new Vec2(s, 0)));
if (this.tileAt(x + 1, y) === '.')
this.walls.push(new Ray2(new Vec2(s * x + s, s * y), new Vec2(0, s)));
if (tile === 'X') {
this.walls.push(new Ray2(new Vec2(s * x, s * y), new Vec2(s, s)));
this.walls.push(new Ray2(new Vec2(s * x + s, s * y), new Vec2(-s, s)));
}
}
}
}
}
}
そして、Gameクラスでプレイヤーの初期位置とマップを生成します。マップは.のみで表現しており、この段階では障害物を設定していません。
// ===================== Game =====================
class Game {
constructor() {
this.level = new Level();
this.player = { pos: new Vec2(100, 200), angle: -PI / 2, speed: 2, radius: 8 };
}
reset() {
const map =
'........' +
'........' +
'........' +
'........' +
'........' +
'........' +
'........' +
'........' +
'........' +
'........';
this.level.addTilemap(map, 8, 10, 35);
this.level.addWorldEdges();
}
}
let game = new Game();
game.reset();
プレイヤーと光線のデモ
キーボードを入力して下のような動きになったら成功です!動きの部分はほぼ完成です。

画面右側に壁と衝突した時の3Dビューを表示させます。レイキャスティングは光線に衝突した壁を表現します。また、壁の端は木の柱で表現しています。平面はコンクリの壁と思ってください。
fov視野角の設定によって見え方が大分違います。
// === 3Dビュー描画 ===
const fov = PI/2;
const centerAngle = player.angle;
const leftAngle = centerAngle - fov / 2;
const beamTotal = 40;
const viewRect = { x: 320, y: 40, w: 300, h: 240 };
for (let i = 0; i < beamTotal; i++) {
const angle = leftAngle + (fov / beamTotal) * i;
const beam = new Ray2(player.pos.copy(), new Vec2(cos(angle), sin(angle)).mult(200));
const hits = level.walls.map(w => beam.intersection(w)).filter(v => v);
if (hits.length === 0) continue;
const hit = hits.reduce((a, b) =>
a.sub(beam.begin).mag() < b.sub(beam.begin).mag() ? a : b);
const hitVec = hit.sub(beam.begin);
const dist = hitVec.mag() * cos(angle - centerAngle);
const lineH = Math.min(3500 / dist, viewRect.h);
const x = viewRect.x + (viewRect.w / beamTotal) * i;
const y = viewRect.y + viewRect.h / 2 - lineH / 2;
const pillarSize = 5;
const lmft = 1.3;
const s = level.tileSize;
if (
((hit.x % s < pillarSize) || (hit.x % s > s - pillarSize)) &&
((hit.y % s < pillarSize) || (hit.y % s > s - pillarSize))
) {
ctx.fillStyle = `rgb(${215 * lmft}, ${179 * lmft}, ${111 * lmft})`; // 木の柱
} else {
const light = 224;
ctx.fillStyle = `rgb(${light}, ${light}, ${light})`; // コンクリ壁
}
ctx.fillRect(x, y, 7, lineH);
}
// === ミニマップ ===
ctx.strokeStyle = "white";
ctx.lineWidth = 1;
for (const wall of level.walls) {
ctx.beginPath();
ctx.moveTo(wall.begin.x, wall.begin.y);
ctx.lineTo(wall.end.x, wall.end.y);
ctx.stroke();
}
// Draw player
ctx.fillStyle = "yellow";
ctx.beginPath();
ctx.arc(player.pos.x, player.pos.y, 5, 0, Math.PI*2);
ctx.fill();
また、1本の光線だと対応する壁は1つだけで3D感がないので、40本の光線を表示し、それに対応する40の壁を表示します。
// 光線描画
function drawRays(ctx, player, walls) {
const fov = PI/2; // 視野角(90°)
const beamCount = 40; // 光線の本数 40
const maxDist = 200; // 光線の最大距離
// ****** 省略 ******
}
壁の描画のデモ
障害物の設置
上のデモだとマップとして寂しいので、障害物を設置してみます。
class Game {
constructor() {
this.level = new Level();
this.player = { pos: new Vec2(100, 200), angle: -PI / 2, speed: 2, radius: 8 };
}
reset() {
const map =
'........' +
'........' +
'..OOO...' +
'..O.....' +
'........' +
'........' +
'........' +
'......O.' +
'OO...OO.' +
'OO...O..';
this.level.addTilemap(map, 8, 10, 35);
this.level.addWorldEdges();
}
}
また、ここまでのコードはGitHubにも上げていますので、htmlやcssを含めて確認してみてください。
https://github.com/TakaTech9021/game/tree/main/fps_game
所感 Chat GPTに助けられてばかりでコードの細かい部分の解説できてないとの指摘は受け付けます。
完成 🎉

高校数学の内容だけでこのような3Dビューができます。Unityなどの便利なライブラリを使わないで、javaScriptを使ってこのような古典的な実装するのも原理を知るのに良いですね。
