HTML・CSS・JavaScriptだけで、ブラウザで動く一人称シューティング「APEX風 バトルロイヤル」を完成させるチュートリアルです。 マウスで見回して撃つ本物の3D視点、ラウンドごとに縮む安全地帯(リング)、物資を漁って装備を整える立ち回り——ゲームエンジンもライブラリも使わず、3Dの絵まで全部自分の手で計算して作ります。
⏱ 読了:約40分 🎯 対象:プログラミング中級者
📋 もくじ
- 完成するとどうなる?
- 準備するもの
- ファイル構成を作ろう
- HTMLを書こう
- CSSでデザインしよう
- JavaScriptの設計を決めよう
- マップを作ろう(64×64のマス目)
- なぜ2Dのマス目が3Dに見えるのか
- DDA法 —— マス目を1つずつたどって壁を探す
- 壁を「短冊」で描く
- 魚眼にしない補正と、模様の貼り方
- 敵やアイテムを描く(ビルボードと深度バッファ)
- プレイヤーを動かそう
- マウスで視点を回す(ポインタロック)
- 撃つ —— ヒットスキャンとヘッドショット
- 敵(ボット)を動かそう
- リング(安全地帯)
- 物資を漁る・レジェンドのアビリティ
- HUDとミニマップ
- 手ざわりを作る演出
- メインループで全部つなげる
- 動かしてみよう
- 次のステップ
- 完成コード全文
完成するとどうなる?
この記事を最後まで読むと、ブラウザ上で遊べる一人称視点のバトルロイヤルFPSが完成します。しかも画像ファイルは1枚も使いません。こんな機能がついています:
- マウスで自由に見回せる本物の3D視点(レイキャスティングで自作)
- 毎回ランダムに生成される64×64マスのマップ(建物・入口・コンテナ)
- ラウンドごとに縮み、外にいるとダメージを受けるリング(安全地帯)
- 9種類の武器(ピストル/SMG/アサルトライフル/ショットガン/スナイパー)
- 物資コンテナを漁って装備を整えるルート(漁り)システム
- アーマー・注射器・シールドセルなどの回復アイテム
- 4人のレジェンド(キャラ)と、それぞれ固有の戦術アビリティ+アルティメット
- 見つける・追う・回り込む・撃つを判断する敵AI
- ヘッドショット判定、距離によるダメージ減衰、反動、ADS(構え)ズーム
- 残り人数・体力バー・弾数・ミニマップ・キルログの入った本格HUD
- タイトル(レジェンド選択)→バトル→リザルトの画面遷移
スマブラ風の対戦アクションで「2Dの物理と当たり判定」に慣れた人が、次に挑む題材としてぴったりです。今回のテーマはひとことで言えば 「2Dのデータから3Dの絵を作る」。ここを乗り越えると、ゲームの見え方そのものが変わります。
準備するもの
以下の2つだけ用意してください。
- テキストエディタ(VS CodeやAntigravityがおすすめ)
- Webブラウザ(Chrome、Edgeなど。マウスの「ポインタロック」を使うのでPC必須です)
BGMを鳴らしたい人は、好きなmp3ファイルを1つ用意しておきましょう(なくてもゲームは動きます)。銃声や着弾音はファイル不要で、JavaScriptがその場で音を合成します。
⚠️ このゲームはスマホでは遊べません。マウスをぐるぐる回して視点を動かす仕組み(ポインタロック)が、PCのブラウザにしかないからです。
ファイル構成を作ろう
パソコンのどこかに apex-battle という名前のフォルダを作って、その中にファイルを用意します。
apex-battle/
index.html ─ 画面の骨組み(キャンバス・HUD・メニュー)
style.css ─ UIの見た目
script.js ─ ゲーム本体(3D描画も含めて全部)
bgm.mp3 ─ BGM(お好みで)
いつもの「HTML・CSS・JS の3点セット」です。ただし今回の script.js は約4200行。これまででいちばん大きなプログラムです。
💡 4000行を「迷子にならない」ように書く
長いプログラムでいちばん怖いのは、書いた本人が読めなくなることです。今回は最初にファイルの先頭へ目次コメントを書いてから中身を書きました。
/* ■ 目次(この順番で書いてあります)
1. 便利な計算・共通の道具
2. 効果音
3. BGM
4. ゲームのデータ(レジェンド・武器・アイテム)
5. 画面(キャンバス)の準備
6. マップ作り
7. 絵の素材作り
8. ゲームの状態
9. 武器と射撃
10. 敵(ボット)の思考
11. リング(安全地帯)
12. 物資と回復アイテム
13. アビリティ
14. キーボードとマウス
15. 毎コマの更新処理
16. 3D描画
17. ミニマップ
18. HUD
19. ゲームの開始・終了
20. メインループ */
順番は「道具 → データ → 状態 → 動き → 見た目 → 進行」。料理でいう下ごしらえの順です。JavaScriptは上から順に読まれるので、使うものは使う前に置くのが基本。目次を先に書いておくと、あとから「あの処理どこだっけ」がゼロになります。
HTMLを書こう
index.html は「キャンバス1枚」+「その上に重ねるUI」という構成です。
<body>
<div id="app">
<canvas id="game"></canvas>
<!-- 戦闘中の画面表示(HUD) -->
<div id="hud" class="hidden">
<div id="stats" class="panel">
<div class="stat"><span class="k">残り</span><span class="v" id="aliveCount">60</span></div>
<div class="stat"><span class="k">キル</span><span class="v" id="killCount">0</span></div>
</div>
<canvas id="minimap" width="184" height="184"></canvas>
<div id="killfeed"></div>
<div id="bottomLeft">
<div class="bars">
<div class="bar shieldbar"><div id="shFill"></div><span id="shText">50</span></div>
<div class="bar hpbar"><div id="hpFill"></div><span id="hpText">100</span></div>
</div>
</div>
<div id="bottomRight">
<div id="ammoBox"><span id="ammoCur">14</span>/<span id="ammoRes">60</span></div>
</div>
</div>
<!-- タイトル/レジェンド選択 -->
<div id="menu">...</div>
<!-- ポーズ/リザルト -->
<div id="pause" class="hidden">...</div>
<div id="result" class="hidden">...</div>
<audio id="bgm" src="bgm.mp3" loop preload="auto"></audio>
</div>
<script src="script.js"></script>
</body>
💡 HUDはCanvasに描かず、HTMLで作る
スマブラ編ではHUDもぜんぶCanvasに描きました。今回は逆に、体力バーも弾数もミニマップの枠もHTMLです。理由は3つあります。
- FPSのHUDは「数字が変わるだけ」の部分が多い。CSSの方が圧倒的にラク
- 体力バーのようなものは
width: 62%と書けば終わり。Canvasだと毎コマ描き直し - Canvasは3Dの計算だけに集中させたい(1コマに何千回もの計算をしている)
作るゲームによって最適な方法が変わる、というのは毎回のテーマですね。今回は「動くものはCanvas、変わるだけのものはHTML」で分担しています。
画面の切り替えは class="hidden" を付けたり外したりするだけ。JavaScriptからは classList.add("hidden") / classList.remove("hidden") で操作します。
CSSでデザインしよう
CSSはHUDとメニューの見た目を担当します。まず色を変数にまとめておきます。
:root {
--orange: #ff5b30; /* 重要な情報・自分の状態 */
--blue: #4fd2ff; /* シールド・戦術アビリティ */
--gold: #ffc23d; /* アルティメット・レアな物 */
--panel: rgba(8, 10, 14, 0.62); /* 半透明の黒パネル */
--line: rgba(255, 255, 255, 0.16);
}
ここの色を書き換えるだけでUI全体の印象が一気に変わります。「マジックナンバーを定数に」はCSSでも同じです。
html, body {
height: 100%;
overflow: hidden; /* スクロールバーを出さない */
}
#game {
position: absolute;
inset: 0; /* top/right/bottom/left: 0 の省略形 */
width: 100%;
height: 100%;
cursor: crosshair;
}
💡 Apexっぽさの正体は「斜めに切られた角」
本家のUIを見ると、パネルの角が斜めにカットされています。これは clip-path 一行で作れます。
.panel {
background: var(--panel);
border: 1px solid var(--line);
backdrop-filter: blur(2px); /* 後ろをぼかす */
/* 右下だけ10px斜めに切り落とす */
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%);
}
polygon() は「多角形の頂点を順番に並べる」書き方。四角形の4つ目の角を2点に分けるだけで、あの近未来っぽい形になります。「らしさ」は意外と小さな1行で作れるという良い例です。
もう1つ大事なのが、HUD全体に付けた pointer-events: none。
#hud { pointer-events: none; }
HUDはキャンバスの上に重なっているので、これがないと「HUDの上をクリックしてしまってゲームに届かない」ことが起きます。「見えるけど触れない」——表示専用の要素には必ず付けましょう。
JavaScriptの設計を決めよう
いつもどおり、まず数字を全部定数にします。今回は数が多いので、意味ごとにグループを分けます。
"use strict";
/* --- 画面の設定 --- */
const RESOLUTION_SCALE = 0.72; // ウィンドウの何倍の解像度で描くか
const COLUMN_WIDTH = 2; // 光線を飛ばす間隔(px)
/* --- マップ --- */
const MAP_W = 64;
const MAP_H = 64;
/* --- 移動(1秒あたり何タイル進むか) --- */
const WALK_SPEED = 3.15;
const SPRINT_SPEED = 4.75;
const CROUCH_SPEED = 1.85;
const GRAVITY = 11;
const JUMP_SPEED = 3.5;
const MAX_PITCH = 0.62; // 上下に向ける限界(ラジアン)
const SLIDE_SECONDS = 0.85; // スライディングが続く時間
const PLAYER_RADIUS = 0.28; // 体の太さ
const EYE_HEIGHT = 0.5; // 目の高さ(0=床、1=天井)
💡 このゲームでいちばん大事な2つの数字
RESOLUTION_SCALE と COLUMN_WIDTH は、動作の軽さを決めるつまみです。
RESOLUTION_SCALE = 0.72… 画面の72%の大きさで描いて、CSSで引き伸ばす。ピクセル数は約半分になるので2倍速くなりますCOLUMN_WIDTH = 2… 2px幅の短冊で描く=光線を飛ばす回数が半分
「重いな」と感じたら 0.6 や 3 にしてみてください。ちょっとカクカクしますが、確実に軽くなります。逆に高性能なPCなら COLUMN_WIDTH = 1 で一気にくっきりします。画質と速さのトレードオフを、数字1個で調整できるようにしておくのがゲームプログラミングの定石です。
データ表:数字を変えるだけでバランス調整
武器・レジェンド・アイテムは「表」として書きます。
const WEAPONS = {
r99: {
name: "R-99 SMG",
type: "smg",
isAuto: true, // 押しっぱなしで連射できるか
tier: 1, // レア度 0=初期装備 3=最強
damage: 11, // 1発のダメージ
headshotMul: 1.5, // 頭に当てたときの倍率
rpm: 1080, // 1分あたりの発射数
magSize: 20,
ammoType: "light",
reloadSeconds: 1.9,
hipSpread: 0.022, // 腰だめ撃ちのブレ
aimSpread: 0.011, // 構えたときのブレ
recoilKick: 0.0055, // 銃口の跳ね上がり
falloffStart: 16, // このタイル数より遠いとダメージ減衰
},
kraber: {
name: "クレーバー .50",
type: "sniper",
isAuto: false, tier: 3,
damage: 145, headshotMul: 2.0,
rpm: 36, zoom: 4.2, magSize: 4,
ammoType: "sniper", reloadSeconds: 3.4,
hipSpread: 0.006, aimSpread: 0.0004, recoilKick: 0.06,
},
// …全部で9種類
};
rpm(1分あたりの発射数)から、次に撃てるまでの間隔は 60 / rpm 秒で求まります。R-99なら 60 / 1080 = 0.055秒、クレーバーなら 60 / 36 = 1.67秒。現実の単位で書いておいて、必要なときに変換すると直感的に調整できます。
レジェンドも同じく表です。
const LEGENDS = [
{
id: "phantom", name: "ファントム", role: "ムーブメント",
color: "#7cc4ff",
hitbox: { height: 0.7, width: 0.5 }, // 当たり判定(1タイル=1.0)
botStats: { speedScale: 1.15, bonusHp: -10, bonusShield: 0, regenPerSec: 0 },
tactical: { name: "ヴォイドラン", cooldown: 18,
text: "3.5秒間 移動速度1.7倍+被ダメージ70%減" },
ultimate: { name: "ヴォイドリープ", chargeSeconds: 95,
text: "視線の先へ瞬間移動する" },
},
// スモーク(重装)/メディック(回復)/ランナー(爆速)
];
面白いのが hitbox。ファントムは細身で width: 0.5、スモークは大柄で width: 0.62 です。強い機動力を持つキャラは的が小さく、頑丈なキャラは的が大きい——数字でバランスを取っているわけです。しかもこの表は「プレイヤーが選ぶキャラ」と「敵のステータス」の両方に使われます。1つの表を2つの用途で使い回せると、設定のズレが起きません。
マップを作ろう(64×64のマス目)
このゲームの世界は、たった1本の配列です。
const MAP_W = 64;
const MAP_H = 64;
/** マップ本体。64×64 の数字を1本の配列に並べて持っている。 */
const mapTiles = new Uint8Array(MAP_W * MAP_H);
数字の意味はこうです。
0 … 何もない(歩ける)
1〜4 … 壁(数字ごとに模様が変わる:コンクリート/鉄板/レンガ/サビ)
5 … コンテナ(遮蔽物)
2次元なのに1本の配列なのは、そのほうが速いから。(x, y) は y * MAP_W + x で取り出します。
/** (x, y) のマスの数字を返す。マップの外は壁(1)扱い。 */
function tileAt(x, y) {
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) return 1;
return mapTiles[y * MAP_W + x];
}
/** 小数の座標がある場所が壁かどうか(x=3.7 は 3 マス目)。 */
function isWallAt(x, y) {
return tileAt(Math.floor(x), Math.floor(y)) > 0;
}
💡 「マップの外は壁」が全部を救う
tileAt が範囲外に 1(壁)を返しているのに注目してください。これだけで、マップの外に出てしまうバグが全部消えます。光線が外へ飛んで行っても「壁に当たった」で止まるし、プレイヤーが端に行っても壁扱いで止まる。境界チェックを何十か所に書く代わりに、入り口の関数1つで面倒を見る——これはとても大事な考え方です。
ランダムなマップを作る
毎回同じマップではつまらないので、自動生成します。手順は3つ。
function generateMap() {
mapTiles.fill(EMPTY);
buildings = [];
// 1. 外周をぐるっと壁で囲む
for (let x = 0; x < MAP_W; x++) { setTile(x, 0, 1); setTile(x, MAP_H - 1, 1); }
for (let y = 0; y < MAP_H; y++) { setTile(0, y, 1); setTile(MAP_W - 1, y, 1); }
// 2. 建物を32棟。ランダムに置いてみて、他とぶつかったらやり直す
let tries = 0;
while (buildings.length < 32 && tries < 1200) {
tries++;
const w = randomInt(4, 11);
const h = randomInt(4, 11);
const x = randomInt(3, MAP_W - w - 3);
const y = randomInt(3, MAP_H - h - 3);
// 既にある建物と 3 マス以上離れているか確認
let isFarEnough = true;
for (const other of buildings) {
const overlapX = x < other.x + other.w + 3 && x + w + 3 > other.x;
const overlapY = y < other.y + other.h + 3 && y + h + 3 > other.y;
if (overlapX && overlapY) { isFarEnough = false; break; }
}
if (!isFarEnough) continue; // ぶつかったので、この案は捨てる
// 壁を四角く並べる(中身は空っぽの部屋になる)
const wallType = randomInt(1, 5);
for (let i = 0; i < w; i++) { setTile(x + i, y, wallType); setTile(x + i, y + h - 1, wallType); }
for (let j = 0; j < h; j++) { setTile(x, y + j, wallType); setTile(x + w - 1, y + j, wallType); }
// 壁を2〜4ヶ所消して入口にする
const doorCount = randomInt(2, 5);
for (let i = 0; i < doorCount; i++) {
const side = randomInt(0, 4);
if (side === 0) setTile(x + randomInt(1, w - 1), y, EMPTY);
else if (side === 1) setTile(x + randomInt(1, w - 1), y + h - 1, EMPTY);
else if (side === 2) setTile(x, y + randomInt(1, h - 1), EMPTY);
else setTile(x + w - 1, y + randomInt(1, h - 1), EMPTY);
}
buildings.push({ x, y, w, h });
}
// 3. 屋外にコンテナを散らす(通路をふさがないよう、まわりが空いている所だけ)
// …
}
💡 「置いてみて、ダメならやり直す」という作り方
建物の配置は、賢い計算をしていません。ランダムに置いて、重なったら捨てる——それを最大1200回くり返すだけです。この方法を「棄却法(rejection sampling)」と言います。
一見すると乱暴ですが、これがとても優秀です。
- 「重ならないように32個並べる」正しいアルゴリズムを考えるのは難しい
- でも「重なっているか判定する」のは4行で書ける
- ランダムに投げていれば、そのうち32個は入る
難しい問題を、簡単な判定+くり返しに置き換える。ゲームの自動生成では本当によく使う発想です。tries < 1200 という上限を付けているのも大事で、これがないと「もう置ける場所がない」状態で無限ループします。
そして屋外コンテナの条件が地味に効いています。
const neighbours =
(tileAt(x + 1, y) ? 1 : 0) + (tileAt(x - 1, y) ? 1 : 0) +
(tileAt(x, y + 1) ? 1 : 0) + (tileAt(x, y - 1) ? 1 : 0);
if (neighbours !== 0) continue; // 隣に何かあるなら置かない
「上下左右がすべて空いている場所にだけ置く」ことで、通路が塞がって行き止まりになるのを防いでいます。自動生成の怖いところは「たまたま通れないマップができる」こと。こういう小さな条件が、遊べるマップを保証します。
なぜ2Dのマス目が3Dに見えるのか
さて、ここからがこの記事の本題です。
いま手元にあるのは、ただの64×64の数字の表です。これをどうやって、あの3D画面にするのか。
答えは、拍子抜けするほどシンプルです。
画面の縦1列ずつ、「そこに見えている壁までの距離」を測る。 近い壁は大きく、遠い壁は小さく描く。それだけ。
これがレイキャスティング(ray casting/光線を飛ばす)という手法です。1992年の『Wolfenstein 3D』が使い、初期のFPSを支えた技術で、いまでも「Canvasで3Dっぽいものを作りたい」ときの第一候補です。
目の前に「板」があると考える
イメージしてみてください。あなたの目の前に、透明な板(=画面)が立っています。
┌─── 画面(幅960px)───┐
│ │
目 ●─────────────┼──光線1──→ 壁(近い) │ → 短冊を高く描く
\ │ │
\──────────┼──光線2──→ 壁(遠い) │ → 短冊を低く描く
\ │ │
\───────┼──光線3──→ 壁(すごく遠い)→ さらに低く
│ │
└───────────────────────┘
画面の横2pxごとに1本ずつ、目から光線を飛ばします。960px幅なら480本。それぞれの光線が最初にぶつかった壁までの距離を測って、
- 距離が近い → 縦に長い短冊を描く(=壁が近くに見える)
- 距離が遠い → 縦に短い短冊を描く(=壁が遠くに見える)
これを480本ぶん左から右へ並べるだけで、目の前に立体的な壁があるように見えます。
💡 3Dの正体は「480本の縦線」
つまりこのゲームの3D画面は、実際には縦の細長い短冊が480枚横に並んでいるだけです。ポリゴンも3D行列も出てきません。
これを知ると「なんだ簡単じゃん」と思うかもしれませんが、逆です。難しく見えるものを、簡単なくり返しに分解できたからこの技術は生まれました。「3Dを描く」という無理そうな課題を、「縦1列の高さを決める」という480回の小さな問題に変えたわけです。
問題を小さく割る。プログラミングでいちばん大事な技術が、ここに凝縮されています。
そして残る問題はただ1つ——「光線が最初にぶつかる壁は、どのマスか?」 これを高速に解くのが、次のDDA法です。
DDA法 —— マス目を1つずつたどって壁を探す
いちばん素朴なやり方は「光線を0.01ずつ進めて、壁に当たるまでループ」です。動きますが、遅い。距離40タイル先まで調べるなら4000回のループを、480本ぶん=192万回。60fpsでは間に合いません。
そこでDDA法(Digital Differential Analyzer)を使います。考え方はこうです。
細かく進むのをやめて、「次のマスの境界」まで一気にジャンプする。
マス目の世界では、壁があるかどうかはマスの境界を越えたときにしか変わりません。だったら境界だけを飛び石のように渡っていけばいい。40タイル進んでも、たかだか80回のループで済みます。
光線の進み方(●が調べる地点)
┌───┬───┬───┬───┬───┐
│ │ │ │ │███│ ← 壁
├───┼───┼───┼───┼───┤
│ │ │ │ │ ● │
├───┼───┼───┼── ●┼───┤
│ │ │ ●┼─● │ │ 縦線と横線、
├───┼── ●┼───┼───┼───┤ 「近いほう」を毎回選んで進む
│目●┼─● │ │ │ │
└───┴───┴───┴───┴───┘
コードで書く
// 光線の向き(cos と sin で「1歩ぶんの矢印」を作る)
const rayX = dirX + planeX * cameraX;
const rayY = dirY + planeY * cameraX;
// 今いるマス
let tileX = Math.floor(player.x);
let tileY = Math.floor(player.y);
// 「マス1個ぶん横に進むのに、光線は何進むか」
// 例:ほぼ真横に飛ぶ光線なら、縦に1マス進むには遠くまで進む必要がある
const stepDistX = Math.abs(1 / (rayX || 1e-9));
const stepDistY = Math.abs(1 / (rayY || 1e-9));
// 進む向き(+1 か -1)と、最初の境界までの距離
let stepX, stepY, nextX, nextY;
if (rayX < 0) { stepX = -1; nextX = (player.x - tileX) * stepDistX; }
else { stepX = 1; nextX = (tileX + 1 - player.x) * stepDistX; }
if (rayY < 0) { stepY = -1; nextY = (player.y - tileY) * stepDistY; }
else { stepY = 1; nextY = (tileY + 1 - player.y) * stepDistY; }
let hitSide = 0; // 0 = 縦線の壁にぶつかった / 1 = 横線の壁
let tile = 0;
let guard = 0;
while (guard++ < 200) {
// 近いほうの境界へ進む
if (nextX < nextY) { nextX += stepDistX; tileX += stepX; hitSide = 0; }
else { nextY += stepDistY; tileY += stepY; hitSide = 1; }
tile = tileAt(tileX, tileY);
if (tile > 0) break; // 壁に当たったので終わり
}
💡 nextX と nextY は「次の縦線/横線までの距離」
このアルゴリズムのキモは、たった2つの変数です。
nextX… 次の縦の境界線に届くまでに、光線が進む距離nextY… 次の横の境界線に届くまでに、光線が進む距離
毎回この2つを比べて、小さいほうへ進む。進んだら、その方向の値に stepDist(1マス進むぶんの距離)を足して更新する。それだけで、光線が通るマスをもれなく、重複なく、順番にたどれます。
なぜ 1 / rayX が「1マス進むぶんの距離」になるのか、少し考えてみてください。rayX は「光線が1進むと、横に何進むか」です。だから逆数の 1 / rayX は「横に1進むには、光線が何進むか」。単位をひっくり返しただけですが、これが全体を支えています。
|| 1e-9 は、真上・真横に飛ぶ光線で rayX が 0 になり 1/0 = Infinity になるのを防ぐお守りです。
guard++ < 200 も忘れずに。「絶対に終わるはず」のループにも上限を付ける——これがフリーズしないゲームの作法です。
壁を「短冊」で描く
壁のマスが見つかったら、距離から短冊の高さを決めて描きます。
// 「画面に垂直な方向の距離」を使うと、端が伸びる魚眼にならない
let depth = hitSide === 0 ? nextX - stepDistX : nextY - stepDistY;
if (depth < 0.02) depth = 0.02;
const columnHeight = screenH / (2 * tanV * depth); // 距離に反比例
const bottomY = horizon + player.eyeZ * columnHeight;
const topY = bottomY - columnHeight;
💡 「距離に反比例」がすべて
columnHeight = screenH / (2 * tanV * depth) ——この分数1本が3Dの正体です。
- 距離が2倍 → 高さは1/2
- 距離が10倍 → 高さは1/10
現実でも同じですよね。10m先の人と20m先の人では、見た目の大きさがちょうど半分になります。遠近法=距離で割り算。それをそのままコードにしただけです。
tanV は視野の広さ(縦方向)。これが小さいほど狭い範囲を大きく映す=ズームになります。あとで出てくるADS(構え)は、この tanV を小さくするだけで実現しています。
const fullZoom = spec && spec.zoom ? spec.zoom : 1.45; // スナイパーは4.2倍など
const targetZoom = player.aimAmount > 0.5 ? fullZoom : 1;
const zoomNow = 1 + (targetZoom - 1) * player.aimAmount; // 途中の状態も滑らかに
tanV = 0.5 / zoomNow;
もう1つ大事なのが bottomY の式です。
const bottomY = horizon + player.eyeZ * columnHeight;
player.eyeZ は目の高さ(0=床、1=天井、ふだんは0.5)。目が高いほど、壁の下端は画面の下のほうに見えます。しゃがむと eyeZ が下がり、それだけで視点が低くなった絵が自動的に描かれる。ジャンプも同じ仕組みです。高さの表現を1つの変数に集約したので、しゃがみもジャンプも1行で済んでいます。
そして horizon(地平線の位置)が上下の視点移動を担当します。
// 上を向くと地平線が下がる(=画面全体が下にずれる)
horizon = screenH / 2 + Math.tan(camPitch) * (screenH / (2 * tanV));
レイキャスティングは本来「上下を向けない」手法ですが、地平線ごと画面をずらすという力技で上下の視点を再現しています。厳密には正しくない(真上を向くと歪む)ので MAX_PITCH = 0.62 ラジアン(約35度)で制限しているわけです。
魚眼にしない補正と、模様の貼り方
魚眼問題
素朴に「目から壁までの直線距離」を使うと、画面の端が引き伸ばされて魚眼レンズのように歪みます。まっすぐな壁が、船底のようにたわんで見えるのです。
原因は、画面の端に向かう光線のほうが斜めに長く進んでいるから。同じ壁なのに端では「遠い」と判定され、短く描かれてしまう。
解決策は「カメラの正面方向に投影した距離を使う」こと。上のコードの nextX - stepDistX が、まさにそれになっています。
// ✕ 目から壁までの直線距離 → 魚眼になる
// ○ カメラの前方向に投影した距離 → まっすぐ見える
let depth = hitSide === 0 ? nextX - stepDistX : nextY - stepDistY;
nextX は「次の境界まで」の値なので、1つ手前に戻すため stepDistX を引きます。そしてこの値は、光線ベクトルの作り方(dirX + planeX * cameraX/前方向の長さが常に1)のおかげで、最初から前方向の距離になっている——という仕掛けです。
💡 バグではなく「仕様の理解不足」
魚眼は、コードが間違っているわけではありません。「距離」という言葉に2つの意味(直線距離/前方向の距離)があることに気づいていないだけ。
こういう 「動くけど、なんか変」 というバグがいちばん厄介です。エラーが出ないので、どこを直せばいいか分からない。こういうときは「自分は今、何を計算しているつもりなのか」を日本語で説明してみると、たいてい食い違いが見つかります。
壁に模様を貼る
同じ色べた塗りだと壁が「板」に見えるので、模様(テクスチャ)を貼ります。素材は画像ファイルではなく、起動時にJavaScriptで描いた64×64のCanvasです。
function texCanvas(kind) {
const c = document.createElement("canvas");
c.width = 64; c.height = 64;
const g = c.getContext("2d");
if (kind === "concrete") {
g.fillStyle = "#6d7480";
g.fillRect(0, 0, 64, 64);
for (let i = 0; i < 900; i++) { // ざらつきを900個の点で表現
g.fillStyle = `rgba(0,0,0,${Math.random() * 0.18})`;
g.fillRect(randomInt(0, 64), randomInt(0, 64), 1, 1);
}
// …
}
return c;
}
const TEX = [null, texCanvas("concrete"), texCanvas("panel"),
texCanvas("brick"), texCanvas("rust"), texCanvas("crate")];
「Canvasに描いた絵を、別のCanvasに貼る」ことができるので、画像ファイルなしでテクスチャが作れます。マップの数字(1〜5)がそのまま TEX の番号になるよう並べてあるのがポイント。
貼るときは、壁のどこに当たったかで切り出す位置を決めます。
// 壁のどのあたりに当たったか(0〜1)
let hitPos = hitSide === 0 ? player.y + depth * rayY : player.x + depth * rayX;
hitPos -= Math.floor(hitPos); // 小数部分だけ取り出す=マスの中の位置
let textureX = Math.floor(hitPos * 64);
// 裏側から見たときに模様が反転しないよう調整
if ((hitSide === 0 && rayX > 0) || (hitSide === 1 && rayY < 0)) {
textureX = 63 - textureX;
}
ctx.drawImage(
TEX[tile] || TEX[1],
textureX, 0, 1, 64, // 元画像から幅1pxの縦線を切り出し
x, Math.floor(topY), COLUMN_WIDTH, drawHeight, // 引き伸ばして描く
);
drawImage の引数が9個あるバージョンは「元画像のここを切り取って、ここに引き伸ばして貼る」という意味。テクスチャから幅1pxの縦線を1本抜き出して、短冊の高さまで縦に引き伸ばしています。
💡 遠いほど暗く=それだけで奥行きが出る
最後に、距離に応じた影を重ねます。
const shade = clamp(depth / 40, 0, 0.66) + (hitSide === 1 ? 0.13 : 0);
if (shade > 0.01) {
ctx.fillStyle = `rgba(4,6,10,${clamp(shade, 0, 0.92)})`;
ctx.fillRect(x, Math.floor(topY), COLUMN_WIDTH, drawHeight);
}
やっていることは「黒い半透明を上から塗る」だけ。でも効果は絶大です。
- 遠いほど暗い(
depth / 40)→ 空気遠近法。距離感が一気に出る - 横向きの壁は少し暗い(
hitSide === 1なら +0.13)→ 光が斜めから当たっている感じ
とくに2つ目。壁の面ごとに明るさを変えるだけで、部屋の角が「折れている」ように見えます。光源の計算を一切していないのに、立体に見える。ローコストで効果が最大の演出です。
敵やアイテムを描く(ビルボードと深度バッファ)
壁は描けました。でも敵はマス目に乗っていません。小数の座標に立っています。どう描けばいいのか。
答えはビルボード(billboard)——「常にこちらを向く1枚絵」として描きます。看板(billboard)をいつもプレイヤーのほうに回転させ続けるイメージです。
「前方向の距離」と「横方向のずれ」に分解する
const dirX = Math.cos(camA), dirY = Math.sin(camA);
const rightX = -dirY, rightY = dirX; // 画面の右方向(前方向を90度回した向き)
for (const sprite of sprites) {
const relX = sprite.x - player.x;
const relY = sprite.y - player.y;
sprite.depth = relX * dirX + relY * dirY; // 前方向の距離
sprite.sideOffset = relX * rightX + relY * rightY; // 右方向のずれ
}
これは内積という計算です。「プレイヤーから見た相対位置」を、「前方向の成分」と「右方向の成分」に分解しています。分解さえできれば、画面上の位置は割り算1つ。
const centerX = ((sprite.sideOffset / (sprite.depth * planeLen) + 1) * screenW) / 2;
const pixelsPerUnit = screenH / (2 * tanV * sprite.depth);
const drawH = sprite.height * pixelsPerUnit; // 壁と同じ「距離で割る」
const drawW = sprite.width * pixelsPerUnit;
pixelsPerUnit の式、見覚えがありますよね。壁の columnHeight とまったく同じです。壁も敵も同じ遠近法で計算しているから、同じ世界にいるように見えるわけです。
💡 深度バッファ —— 壁の裏の敵を隠す
ここが最大の山場です。素直に描くと、建物の裏にいる敵が壁を透けて見えてしまいます。
解決策は「壁を描くときに、各列の距離をメモしておく」こと。
// drawWalls() の最後
for (let k = 0; k < COLUMN_WIDTH; k++) wallDepth[x + k] = depth;
この wallDepth は、画面の横1pxごとに「そこに見えている壁までの距離」を持つ配列です。これを深度バッファ(Zバッファ)と言います。
敵を描くときは、1列ずつこう判定します。
const visible = wallDepth[x] > sprite.depth; // 壁のほうが遠い=敵が手前=見える
壁より手前にいる列だけを描く。これで敵が建物の陰に半分だけ隠れるという自然な見え方になります。
実際のコードでは、見える列が連続している区間をまとめて drawImage しています。1pxずつ480回描くより、まとまった区間を数回で描くほうがずっと速いからです。
const paint = () => {
let runStart = -1;
for (let x = startX; x <= endX + 1; x++) {
const visible = x <= endX && wallDepth[x] > sprite.depth;
if (visible && runStart < 0) runStart = x; // 見える区間の始まり
if (!visible && runStart >= 0) { // 区間の終わり → まとめて描く
const u0 = (runStart - leftX) / drawW; // 元画像のどこからどこまで(0〜1)
const u1 = (x - leftX) / drawW;
ctx.drawImage(sprite.image,
u0 * sprite.image.width, 0,
Math.max(0.5, (u1 - u0) * sprite.image.width), sprite.image.height,
runStart, topY, x - runStart, drawH);
runStart = -1;
}
}
};
描く順番も大事
sprites.sort((a, b) => b.depth - a.depth); // 遠い順に並べる
for (const sprite of sprites) drawSprite(sprite);
遠いものから順に描いて、手前のもので上塗りしていく。この方法を画家のアルゴリズム(painter's algorithm)と言います。画家が背景から描いて手前を重ねるのと同じですね。壁は深度バッファで正確に処理し、スプライト同士は並べ替えで処理する——用途に応じて手法を使い分けるのがコツです。
そして被弾フラッシュが、ちょっとした発明です。
if (sprite.flash) {
// 同じ絵をもう一度「光」として重ねると、その形だけが光る
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 0.55;
paint();
}
globalCompositeOperation = "lighter" は「重ねた色を足し算する」モード。同じ絵をもう一度重ねるだけで、キャラの形のまま白く光ります。専用の「光った絵」を用意する必要がありません。
プレイヤーを動かそう
移動は「目標の速度を決めて、そこへ少しずつ近づける」方式です。
function updatePlayerMovement(dt) {
const { forward, side } = readMoveInput(); // WASDを -1〜1 の数に変換
// 目標の速度(この向き・この速さで動きたい)
let wantX, wantY;
if (forward === 0 && side === 0) {
wantX = 0; wantY = 0;
} else {
const speed = currentMoveSpeed(wantSprint);
const length = Math.hypot(forward, side); // 斜め移動が速くならないように
const f = forward / length;
const s = side / length;
wantX = (Math.cos(player.angle) * f + Math.cos(player.angle + Math.PI / 2) * s) * speed;
wantY = (Math.sin(player.angle) * f + Math.sin(player.angle + Math.PI / 2) * s) * speed;
}
// いきなり目標速度にせず、少しずつ近づけると「滑る」感じが出る
player.speedX = lerp(player.speedX, wantX, clamp(dt * 14, 0, 1));
player.speedY = lerp(player.speedY, wantY, clamp(dt * 14, 0, 1));
moveWithCollision(player, player.speedX * dt, player.speedY * dt, PLAYER_RADIUS);
}
💡 斜め移動が速くなるバグ、知ってますか?
const length = Math.hypot(forward, side) で割っているところ。ここを省くと、WとDを同時に押したとき約1.41倍速く動きます。
前に1、右に1動くと、斜めの移動距離は √(1²+1²) = 1.41 になるからです。これは古いFPSに実際にあったバグで、「ストレイフ移動」という高速移動テクニックとして使われていました。
直し方は、ベクトルの長さで割って長さ1にそろえる(正規化)だけ。ゲームで「向き」を扱うときは、長さ1のベクトルにそろえるのが鉄則です。
💡 lerp で滑らせる
lerp(現在, 目標, 0.2) は「現在と目標のあいだを20%進む」という計算です。毎コマ呼ぶと、目標へ指数関数的に近づくなめらかな動きになります。
player.speedX = lerp(player.speedX, wantX, clamp(dt * 14, 0, 1));
キーを離した瞬間にピタッと止まらず、少し滑って止まる。この「少しの慣性」が、FPSの気持ちよさをかなりの部分決めています。数字の 14 を小さくするとツルツル滑る氷の上に、大きくするとキビキビしたアーケード的な操作感になります。遊んでみて決める数字の代表格です。
clamp(dt * 14, 0, 1) で1以下に制限しているのも大事。もしフレームが飛んで dt が大きくなっても、1を超えて「行き過ぎる」ことがありません。
壁ずり —— 壁に沿って滑る
function moveWithCollision(thing, dx, dy, radius) {
if (!hitsWall(thing.x + dx, thing.y, radius)) thing.x += dx; // 横だけ試す
else thing.bump = true;
if (!hitsWall(thing.x, thing.y + dy, radius)) thing.y += dy; // 縦だけ試す
else thing.bump = true;
}
縦と横を別々に判定する——たったこれだけで、壁に斜めからぶつかったときに壁沿いにスルスル滑ります。もし if (!hitsWall(x+dx, y+dy)) とまとめて判定すると、壁に斜めから当たった瞬間に完全停止してしまい、非常にストレスの溜まる操作感になります。
FPSの「壁に引っかからない気持ちよさ」の正体は、この2行に分けるという小さな工夫です。
ジャンプとしゃがみ
上下の動きは、目の高さ eyeZ を動かすだけです。
function updateJump(dt) {
const groundEyeZ = EYE_HEIGHT - player.crouchAmount * CROUCH_EYE_DROP;
const isInAir = player.verticalSpeed !== 0 || player.eyeZ > groundEyeZ + 0.001;
if (!isInAir) { player.eyeZ = groundEyeZ; return; }
player.verticalSpeed -= GRAVITY * dt; // 重力
player.eyeZ += player.verticalSpeed * dt;
if (player.eyeZ <= groundEyeZ) { // 着地
player.eyeZ = groundEyeZ;
player.verticalSpeed = 0;
}
}
スマブラ編の重力とまったく同じ「速度に重力を足す→位置に速度を足す」です。2Dで身につけた物理が、そのまま3Dでも使える——ここが分かると世界が広がります。
しゃがみは crouchAmount(0〜1)を滑らかに動かして、地面の高さ自体を下げています。
const crouchChange = wantCrouch || player.slideTimer > 0 ? dt * 7 : -dt * 7;
player.crouchAmount = clamp(player.crouchAmount + crouchChange, 0, 1);
ON/OFFではなく0〜1の連続値にすることで、しゃがみ動作がぬるっと繋がります。
スライディング
ダッシュ中にしゃがむと、Apexらしいスライディングが発動します。
const canSlide = wantCrouch && wantSprint && player.slideTimer <= 0
&& player.eyeZ <= 0.55 && moveSpeedNow() > 3.6;
if (canSlide) startSlideIfPossible(forward, side);
条件が5つもあるのは、暴発を防ぐためです。「地上にいて」「十分な速度が出ていて」「まだスライディング中でない」——このどれかが欠けると、ジャンプ中に滑ったり、止まっているのに滑り出したりします。
スライディング中は入力を無視して、開始時の向きに滑り続けます。
if (player.slideTimer > 0) {
// だんだん遅くなる(残り時間に比例)
const boost = 3.2 + 4.5 * (player.slideTimer / SLIDE_SECONDS);
wantX = player.slideDirX * boost;
wantY = player.slideDirY * boost;
}
開始直後は 3.2 + 4.5 = 7.7(ダッシュの1.6倍!)、終わりぎわは 3.2。始めが速くて終わりが遅いから、勢いよく飛び出して減速する感じが出ます。
マウスで視点を回す(ポインタロック)
FPSでは、マウスを右に動かし続ければ無限に右を向けます。でも普通のWebページでは、マウスは画面の端で止まってしまう。
これを解決するのがポインタロック(Pointer Lock API)です。
gameCanvas.addEventListener("mousedown", (e) => {
if (document.pointerLockElement !== gameCanvas) {
gameCanvas.requestPointerLock(); // マウスカーソルを消して固定する
return;
}
if (e.button === 0) mouseDown = true;
if (e.button === 2) rightDown = true;
});
window.addEventListener("mousemove", (e) => {
if (document.pointerLockElement !== gameCanvas || state !== "play") return;
player.angle += e.movementX * SENS;
player.pitch = clamp(player.pitch - e.movementY * SENS, -MAX_PITCH, MAX_PITCH);
});
💡 clientX ではなく movementX
普通のマウスイベントで使う e.clientX は「画面のどこにカーソルがあるか」。ポインタロック中はカーソルが存在しないので使えません。
代わりに使うのが e.movementX——「前回から何px動いたか」です。位置ではなく変化量。だから画面の端でも関係なく、いくらでも回り続けられます。
「位置」ではなく「変化」を見る。ゲームの入力ではよく出てくる発想の転換です。
スコープを覗いているときは感度を下げているのも実用的な配慮です。
const zoomAdj = player.aimAmount > 0.5 ? (wDef() && wDef().zoom ? 0.45 : 0.7) : 1;
player.angle += e.movementX * SENS * zoomAdj;
4倍ズームしているとき、同じマウス移動で同じ角度動くと画面上では4倍速く見えて狙えません。ズーム倍率に合わせて感度を落とすのは、実際のFPSでも標準的な仕様です。
ESCでポーズ、が自動でできる
document.addEventListener("pointerlockchange", () => {
if (document.pointerLockElement !== gameCanvas && state === "play") {
state = "pause";
byId("pause").classList.remove("hidden");
mouseDown = rightDown = false;
for (const k in keys) keys[k] = false; // 押しっぱなしをリセット
}
});
ブラウザはESCキーで自動的にポインタロックを解除します。だから「ロックが外れた=ポーズにする」と書くだけで、ESCポーズが完成します。しかもタブを切り替えたときも同じイベントが飛ぶので、裏でゲームが進み続けるバグも自動的に防げます。
for (const k in keys) keys[k] = false; を忘れずに。これがないと、Wを押しながらタブを切り替えたときに keys.KeyW が true のまま残り、戻ってきたら勝手に前進し続けます。「離した」イベントが来ないケースを想像するのは、入力処理のいちばん大事な習慣です。
撃つ —— ヒットスキャンとヘッドショット
このゲームの弾は、飛んでいきません。引き金を引いた瞬間に、当たったかどうかが決まります。これをヒットスキャン(hitscan)と言います。
function tryFire() {
const w = curW();
if (!w) return;
if (player.shootCooldown > 0 || player.reloadTimer > 0 || player.healTimer > 0) return;
if (w.inMag <= 0) { Sound.click(); startReload(); return; }
const d = w.def;
w.inMag--;
player.shootCooldown = 60 / d.rpm; // 連射速度
Sound.shot(d.type, 1);
// ブレの大きさを状況で変える
const adsF = player.aimAmount > 0.5 ? 1 : 0;
let spread = adsF ? d.aimSpread : d.hipSpread; // 構えていればブレが小さい
if (player.crouchAmount > 0.5) spread *= 0.75; // しゃがむとさらに小さい
if (moveSpeedNow() > 3.5) spread *= 1.6; // 走りながらだと大きい
const pellets = d.pellets || 1; // ショットガンは複数の粒
const cone = spread * (pellets > 1 ? 1.6 : 1) * 2.2;
for (let i = 0; i < pellets; i++) {
const sa = player.angle + bellRandom() * cone; // 左右のブレ
const sp = player.pitch + bellRandom() * cone; // 上下のブレ
shootRay(sa, sp, d);
}
// 反動
player.recoilSpeed += d.recoilKick * (adsF ? 0.75 : 1) * 42;
}
💡 bellRandom() —— ただの乱数では「らしく」ならない
ブレに Math.random() を使うと、どの角度も同じ確率で外れます。実際の銃はそうではなく、「だいたい狙ったところ、たまに大きく外れる」はず。
そこで使うのがこの関数。
function bellRandom() {
const sum = Math.random() + Math.random() + Math.random() + Math.random();
return (sum - 2) / 2; // -1〜+1、ただし0付近が出やすい
}
乱数を4回足すだけで、真ん中が出やすい釣鐘型(正規分布っぽい形)になります。サイコロ1個より2個のほうが真ん中の目の合計が出やすいのと同じ理屈で、中心極限定理という統計の性質です。
たった1行の違いですが、撃ち心地はまるで変わります。均一な乱数では「外れる時は完全にランダム」で理不尽に感じ、釣鐘型なら「だいたい当たるけど、たまに惜しい」になる。乱数の"形"を選ぶという発想を持っておくと、演出の幅が一気に広がります。
当たり判定:光線と円柱の交差
function shootRay(ang, pit, d) {
const dx = Math.cos(ang), dy = Math.sin(ang);
const wall = castRay(player.x, player.y, ang); // まず壁までの距離を測る
let best = null, bestD = wall.dist;
for (const enemy of enemies) {
if (!enemy.isAlive) continue;
const rx = enemy.x - player.x, ry = enemy.y - player.y;
const ty = rx * dx + ry * dy; // 光線方向の距離(内積)
if (ty < 0.3 || ty > bestD) continue; // 後ろ、または壁より遠い
const lat = Math.abs(-rx * dy + ry * dx); // 光線からの横方向のずれ(外積)
if (lat > enemy.radius) continue; // 太さの外なので外れ
const h = player.eyeZ + Math.tan(pit) * ty; // その距離での弾の高さ
if (h < 0.02 || h > enemy.height) continue; // 足元より下/頭より上
best = { bot: enemy, dist: ty, head: h > enemy.height * 0.78 }; // 上位22%が頭
bestD = ty; // より近い敵を優先
}
// …ダメージ計算…
}
💡 「壁より遠い敵には当たらない」を1行で
let bestD = wall.dist; の初期値がポイントです。最初から壁までの距離を入れておくことで、それより遠い敵は自動的に continue で弾かれます。「壁を貫通して当たる」バグが、変数の初期値だけで消えるわけです。
そして bestD = ty と更新していくので、手前の敵が優先される(=敵の後ろの敵には当たらない)ようにもなっています。1つの変数が2つの仕事を同時にこなしている、気持ちのいいコードです。
ヘッドショットは h > enemy.height * 0.78——身長の上から22%に当たったら頭、という単純な判定。それでも headshotMul: 2.15 のマグナムが頭に入ったときの快感は本物です。
距離によるダメージ減衰も入っています。
if (d.falloffStart && best.dist > d.falloffStart)
dmg *= clamp(1 - (best.dist - d.falloffStart) / (d.falloffStart * 1.6), 0.35, 1);
if (best.head) dmg *= d.headshotMul;
ショットガンは falloffStart: 9(9タイル)を超えると急速に弱くなり、スナイパーには減衰がありません。この数字だけで「武器の役割」が決まる——近距離武器と遠距離武器の住み分けが、表の1行で生まれています。
反動:撃つと上を向き、ゆっくり戻る
player.pitch = clamp(player.pitch + player.recoilSpeed * dt, -MAX_PITCH, MAX_PITCH);
player.recoilPitch += player.recoilSpeed * dt; // 上げた量を覚えておく
player.recoilSpeed = lerp(player.recoilSpeed, 0, clamp(dt * 12, 0, 1));
if (player.recoilPitch > 0) { // 覚えた量だけ戻す
const back = Math.min(player.recoilPitch, dt * 0.55);
player.pitch -= back;
player.recoilPitch -= back;
}
recoilPitch に「反動で上げた量」を貯めておいて、あとから同じだけ下げて返します。だからプレイヤーが自分でマウスを下げた場合は戻らない(=リコイル制御が効く)。上級者が反動を抑えて撃てる余地を残しているわけです。
敵(ボット)を動かそう
敵は18体がマップに配置され、それぞれ独立に考えて動きます。処理は毎コマ5ステップ。
function updateBot(enemy, dt) {
/* --- 1. プレイヤーが見えているか --- */
const distToPlayer = Math.hypot(player.x - enemy.x, player.y - enemy.y);
const canSeePlayer =
!player.isDead &&
distToPlayer < enemy.sightRange && // 視界の範囲内
canSee(enemy.x, enemy.y, player.x, player.y) && // 壁に遮られていない
!smokeBlocksView(enemy.x, enemy.y, player.x, player.y); // 煙幕に遮られていない
if (canSeePlayer) {
enemy.alertTimer = ENEMY_ALERT_SECONDS; // 6秒間は覚えている
enemy.lastSeenPos = { x: player.x, y: player.y };
} else {
enemy.alertTimer -= dt;
}
// …2. どこへ向かうか 3. 壁にはまっていないか
// 4. 射撃 5. リング判定
}
💡 「最後に見た場所」を覚えるだけでAIが賢く見える
lastSeenPos と alertTimer の2つ。これだけで敵の印象がガラリと変わります。
- プレイヤーが物陰に隠れる → 敵はすぐ見失う
- でも最後に見た場所へ6秒間は向かってくる
- そこに着いても居なければ、また徘徊に戻る
これがないと、隠れた瞬間に敵が興味を失って明後日の方向へ歩き出し、バカに見えます。逆に常に位置を知っていると、理不尽で強すぎます。「少し前の情報を持っている」という中間状態が、いちばん人間らしいのです。
視線判定 canSee は、2点間を細かく刻んで壁があるか見るだけ。
function canSee(x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const steps = Math.ceil(Math.hypot(dx, dy) / 0.24); // 0.24タイルごとに調べる
for (let i = 1; i < steps; i++) {
const t = i / steps;
if (isWallAt(x1 + dx * t, y1 + dy * t)) return false;
}
return true;
}
DDA法でも書けますが、こちらは短くて読みやすい。処理速度が問題にならないところは、素朴な方法でいいという判断です。
交戦中は「回り込む」
if (canSeePlayer) {
enemy.strafeTimer -= dt;
if (enemy.strafeTimer <= 0) {
enemy.strafeTimer = randomBetween(0.8, 2);
enemy.strafeDir *= -1; // たまに左右を入れ替える
}
const sideX = -moveY * enemy.strafeDir; // 進行方向の真横
const sideY = moveX * enemy.strafeDir;
const forward = distToPlayer < 7 ? -0.5 : 0.85; // 近すぎたら下がる
moveX = moveX * forward + sideX * 0.9;
moveY = moveY * forward + sideY * 0.9;
}
まっすぐ突っ込んでくる敵は、ただの的です。「前進 × 0.85 + 横移動 × 0.9」を混ぜることで、斜めに回り込みながら詰めてくる動きになります。近づきすぎたら forward がマイナス(後退)に切り替わるので、適切な距離を保とうとします。
命中は「確率」で決める
let hitChance = enemy.accuracy * clamp(1 - distToPlayer / (enemy.shootRange * 1.5), 0.25, 1);
if (player.slideTimer > 0 || moveSpeedNow() > 4.5) hitChance *= 0.7; // 動いてると当たりにくい
if (player.crouchAmount > 0.5) hitChance *= 1.1; // しゃがみは的が止まる
if (Math.random() < hitChance) {
damagePlayer(enemy.damagePerShot * randomBetween(0.8, 1.3), enemy.x, enemy.y);
}
敵の弾はレイキャストしていません。サイコロを振るだけです。でもプレイヤー側から見ると「動き回ると当たりにくい」「立ち止まると撃たれる」という、まさにFPSらしい駆け引きが成立します。
💡 見た目が同じなら、中身は簡単でいい
プレイヤーの射撃は精密なレイキャスト、敵の射撃は確率——非対称です。でもこれは手抜きではなく、正しい設計判断です。
- プレイヤーの弾は「当たった/外れた」がはっきり見えるので、正確さが必要
- 敵の弾は見えない(発砲光とダメージだけ)ので、結果が同じなら中身は何でもいい
18体×毎コマのレイキャストは重いですし、正確にやると今度は「敵が正確すぎて理不尽」という調整地獄が待っています。プレイヤーに見えないところの精度は落としていい——これはプロの現場でも常識のテクニックです。
壁にはまったら諦める
const movedDistance = distance(enemy.x, enemy.y, enemy.prevX, enemy.prevY);
if (movedDistance < 0.05) {
enemy.stuckTimer += dt;
if (enemy.stuckTimer > 0.8) {
enemy.waypoint = findOpenSpot(null, 0); // 別の場所を目指してやり直す
enemy.stuckTimer = 0;
}
}
このゲームの敵は経路探索(A*など)をしていません。目的地へまっすぐ向かうだけなので、当然壁の角にはまります。
そこで「0.8秒動けなかったら、目的地を変える」。賢く解くのではなく、詰んだら諦めて別の手を打つ。実用的で、しかも人間っぽく見えます。完璧なAIを作るより、破綻しないAIを作るほうがずっと大事です。
リング(安全地帯)
バトルロイヤルの心臓部。時間とともに縮む円です。
const RING_STAGES = [
{ wait: 40, close: 60, r: 0.4, dps: 2 }, // 待ち40秒 → 60秒かけて縮む
{ wait: 38, close: 55, r: 0.29, dps: 3 },
{ wait: 34, close: 50, r: 0.2, dps: 5 },
{ wait: 30, close: 45, r: 0.13, dps: 10 },
{ wait: 26, close: 40, r: 0.07, dps: 15 },
{ wait: 22, close: 40, r: 0.02, dps: 25 },
];
r はマップ幅に対する割合、dps は外にいるときの毎秒ダメージ。ラウンドが進むほど、待ち時間は短く・円は小さく・ダメージは強くなります。この6行が、試合のテンポそのものです。
縮む処理は lerp 一発。
const k = 1 - clamp(ring.t / s.close, 0, 1); // 0(開始)→ 1(完了)
ring.cx = lerp(ring.scx, ring.tcx, k); // 中心も移動する
ring.cy = lerp(ring.scy, ring.tcy, k);
ring.r = lerp(ring.sr, ring.tr, k);
開始時の値(scx, scy, sr)と目標の値(tcx, tcy, tr)を覚えておいて、残り時間から求めた進捗 k で補間する。移動アニメーションの基本形です。中心も一緒に動くので、「安全地帯が移動していく」という緊張感が生まれます。
次のリングの中心は、現在のリングの中に収まる範囲でランダムに決めます。
function planNextRing() {
const s = RING_STAGES[ring.stage];
const nr = MAP_W * s.r;
const maxOff = Math.max(0, ring.r - nr); // ずらせる最大距離
const ang = randomBetween(0, TAU);
const dd = randomBetween(0, maxOff * 0.8);
ring.tcx = clamp(ring.cx + Math.cos(ang) * dd, nr + 2, MAP_W - nr - 2);
ring.tcy = clamp(ring.cy + Math.sin(ang) * dd, nr + 2, MAP_H - nr - 2);
ring.tr = nr;
}
maxOff = ring.r - nr が「はみ出さないための上限」。さらに * 0.8 で余裕を持たせ、最後に clamp でマップ外に出ないよう押さえています。3段構えで安全を確保しているのは、ここが破綻すると「絶対に助からないリング」ができてしまうからです。
💡 「その他の選手」は嘘でいい
このゲームは60人バトルロイヤルですが、実際に動いている敵は18体だけです。残りの41人はどうしているのか。
function updateOtherSquads(dt) {
simTimer -= dt;
if (simTimer > 0 || simRemain <= 0) return;
simTimer = randomBetween(3.5, 11); // 次に誰かが倒れるまでの秒数
simRemain--;
alive = Math.max(1, alive - 1); // 残り人数を減らす
const winner = pickOne(TAGS) + pickOne(NAMES);
const loser = pickOne(TAGS) + pickOne(NAMES);
addFeed(`${winner} ⟶ ${loser}`); // キルログに流す
}
数秒に1回、存在しない選手が存在しない選手を倒したというログを流し、残り人数を減らすだけ。それでもプレイヤーには「画面の外でも戦いが続いている」と感じられます。
もし60体を本当に動かしたら、AI処理は3倍以上重くなります。でもプレイヤーが実際に会うのはせいぜい数体。見えないものは、見えるようにフリをするだけでいい。ゲーム開発の残酷で美しい真実です。
物資を漁る・レジェンドのアビリティ
ルート(漁り)
物資コンテナは建物の中と屋外に置かれ、tier(レア度)を持ちます。
function rollLoot(tier) {
const out = [];
const n = tier === 2 ? randomInt(4, 6) : randomInt(2, 5); // 良い箱ほど中身が多い
for (let i = 0; i < n; i++) {
const r = Math.random();
if (r < 0.30) out.push({ kind: "weapon", /* … */ }); // 30% 武器
else if (r < 0.62) out.push({ kind: "ammo", /* … */ }); // 32% 弾薬
else if (r < 0.88) out.push({ kind: "heal", /* … */ }); // 26% 回復
else out.push({ kind: "armor", /* … */ }); // 12% アーマー
}
return out;
}
Math.random() の値を区間で分けるのが重み付き抽選の定番です。数字を書き換えるだけで「武器が出やすい世界」「弾切れしやすい世界」に変えられます。
拾ったときの処理には、地味な親切が入っています。
} else if (w.def.tier > player.weapons[player.activeSlot].def.tier) {
showToast(`${w.def.name} に持ち替え`);
player.weapons[player.activeSlot] = w;
} else return; // 今より弱い武器なら拾わない
今より弱い武器は自動で拾わない。これがないと、レアな武器を持っているのに漁った瞬間ピストルに持ち替えられて絶望します。「プレイヤーの不利になる自動処理は入れない」——UXの鉄則です。
回復アイテムの選び方も同じ発想です。
if (player.items.medkit > 0 && player.hp <= 50) id = "medkit"; // 瀕死なら大きいほう
else if (player.items.syringe > 0) id = "syringe"; // ふだんは小さいほう
else if (player.items.medkit > 0) id = "medkit";
3キーを押すだけで、状況に応じて最適なアイテムが自動で選ばれます。「体力が半分以下なら医療キット、そうでなければ注射器」——プレイヤーが考えなくていいことは、プログラムが考える。良いUIの定義そのものです。
4人のレジェンド、4つの個性
アビリティは legend.id で分岐するだけのシンプルな実装ですが、種類がまるで違います。
function useTactical() {
if (player.tacticalCooldown > 0 || player.isDead) return;
const L = legend.id;
if (L === "phantom") { // 高速化+ダメージ軽減
player.phaseTimer = 3.5;
} else if (L === "smoke") { // 視線の先に煙幕を出す
const r = castRay(player.x, player.y, player.angle);
const d = Math.min(r.dist - 0.4, 11); // 壁の手前で止まる
smokes.push({ x: player.x + Math.cos(player.angle) * d,
y: player.y + Math.sin(player.angle) * d, r: 3.2, t: 16 });
} else if (L === "medic") { // 回復ドローンを設置
drones.push({ x: player.x, y: player.y, t: 14 });
} else if (L === "runner") { // 体力を削って加速
player.hp = Math.max(1, player.hp - 10);
player.stimTimer = 6;
}
player.tacticalCooldown = legend.tactical.cooldown;
}
💡 レイキャストは「見る」以外にも使える
スモークのアビリティで castRay を呼んでいるのに注目してください。
const r = castRay(player.x, player.y, player.angle);
const d = Math.min(r.dist - 0.4, 11); // 壁までの距離か、11タイルの近いほう
3Dを描くために作った関数が、そのまま「視線の先の壁までどれだけあるか」の計算に使えます。だから煙幕が壁を貫通して向こう側に出ることがない。
ファントムのアルティメット(瞬間移動)も同じです。
const r = castRay(player.x, player.y, player.angle);
const d = Math.min(r.dist - 0.5, 9);
if (d > 1) {
player.x += Math.cos(player.angle) * d;
player.y += Math.sin(player.angle) * d;
}
壁の手前で止まるので、壁の中にワープして詰むことがありません。1つのよくできた関数が、描画・アビリティ・射撃の3か所で使い回される。「汎用の部品を1つ作る」ほうが「専用の処理を3つ作る」よりずっと強いという好例です。
そして煙幕は、敵AIの視線判定にも効いています。
const canSeePlayer = /* … */ && !smokeBlocksView(enemy.x, enemy.y, player.x, player.y);
煙で本当に敵から見えなくなる。見た目だけの演出ではなく、ちゃんとゲームのルールとして機能しているのが大事なところです。
HUDとミニマップ
変わったときだけ書き換える
HUDはHTMLなので更新はラクですが、毎コマ全部書き換えると意外と重くなります。
let lastHud = {};
function setTxt(id, value) {
if (lastHud[id] === value) return; // 前と同じなら何もしない
byId(id).textContent = value;
lastHud[id] = value;
}
DOMの書き換えはCanvasの描画よりずっとコストが高い処理です。「前回の値を覚えておいて、変わったときだけ触る」——たった3行で、無駄な更新がほぼゼロになります。
ミニマップ
ミニマップは「マップ全体を64×64pxの画像として1回だけ描いておき、毎コマは切り出して貼るだけ」です。
/** ミニマップ用の小さな画像を作る(1マス = 1px)。 */
function buildMiniMapImage() {
miniMapImage = document.createElement("canvas");
miniMapImage.width = MAP_W;
miniMapImage.height = MAP_H;
const g = miniMapImage.getContext("2d");
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const tile = tileAt(x, y);
if (tile === EMPTY) continue;
g.fillStyle = tile === CRATE_TILE ? "#4a5566" : "#6b7787";
g.fillRect(x, y, 1, 1);
}
}
}
1マス=1pxという思い切った作り方。64×64マスのマップが、そのまま64×64pxの画像になります。あとはこれを拡大して貼り、上にプレイヤーとリングの円を描くだけ。
💡 「変わらないものは1回だけ描く」
マップは試合中ずっと変わりません。なのに毎コマ4096マスをループしていたら大損です。変化しないものは、事前に画像にしておく——このテクニックをキャッシュと呼びます。
同じ発想が壁のテクスチャ(起動時に1回作る)、キャラの立ち絵(レジェンドごとに1回作る)にも使われています。ゲームを軽くする方法の8割は「同じ計算を2回しない」に集約されます。
手ざわりを作る演出
FPSの気持ちよさは、細かい演出の積み重ねです。
① 銃の揺れ(ビューモデル)
// 歩くほど揺れる。構えると揺れは止まる
const swayX = Math.sin(player.walkCycle) * 9 * (1 - aim);
const swayY = Math.abs(Math.cos(player.walkCycle)) * 7 * (1 - aim);
walkCycle は移動速度に比例して増える数。それを Math.sin に入れるだけで、歩くリズムに合わせた揺れになります。(1 - aim) を掛けているので、構えると揺れがピタッと止まる——「集中している感じ」が出ます。
縦揺れに Math.abs(絶対値)が付いているのがミソ。上下対称の波ではなく「下がって、上がって、下がって」という足取りらしい動きになります。
② 画面揺れ
const shakeX = player.shakeAmount * randomBetween(-0.012, 0.012);
camA = player.angle + shakeX; // カメラの角度をずらす
player.shakeAmount = Math.max(0, player.shakeAmount - dt * 4); // だんだん収める
揺らすのはカメラの角度です。描画位置ではなく視点そのものを揺らすので、壁も敵もHUD以外の全部が一緒に揺れる。爆発の衝撃が体に来る感じになります。
③ ダメージ数字
敵に当てると「24」のような数字が浮かび、シールドに当たれば青、体力なら白、ヘッドショットなら大きく表示されます。「効いている」というフィードバックがあるかどうかで、撃つ楽しさは何倍も変わります。
④ 被弾方向の矢印
dirIndicators.push({ ang: Math.atan2(fromY - player.y, fromX - player.x), t: 0 });
撃たれたとき、どっちから撃たれたかを画面の縁に矢印で出します。FPSでは背後から撃たれても振り向けないと理不尽なので、この矢印は演出ではなくゲームの公平性の一部です。
⑤ 音を合成する
効果音のファイルは1つも使いません。材料はたった2つ、ノイズ(ザーッ)とトーン(ピー)だけ。
shot(weaponClass, volume) {
if (weaponClass === "shotgun") {
this.playNoise(700, 0.6, 0.28, 0.9 * v, "lowpass"); // 低くて太いノイズ
this.playTone(180, 45, 0.22, 0.5 * v, "square"); // 低い唸り
} else if (weaponClass === "pistol") {
this.playNoise(1700, 1.2, 0.11, 0.55 * v); // 高くて短いノイズ
this.playTone(300, 90, 0.09, 0.28 * v, "square");
}
}
「低い+長い=重い音」「高い+短い=軽い音」。この直感だけで、ショットガンとピストルの撃ち分けができます。しかも敵の銃声は距離に応じて音量を変えているので(clamp(1 - distToPlayer / 26, 0.05, 1))、遠くの銃声で敵の位置が分かるというFPSらしい情報にもなっています。
メインループで全部つなげる
いよいよ心臓部です。
let last = performance.now();
function frame(t) {
const dt = Math.min(0.05, (t - last) / 1000); // 前のコマからの経過秒数
last = t;
if (state === "play") {
update(dt);
}
if (state === "play" || state === "pause") {
render(); // 3D描画
updateHUD(); // HUDの数字
drawMinimap(); // ミニマップ
} else {
ctx.fillStyle = "#05070a";
ctx.fillRect(0, 0, screenW, screenH);
}
BGM.update(dt);
requestAnimationFrame(frame);
}
💡 dt は必ず上限を付ける
const dt = Math.min(0.05, (t - last) / 1000);
Math.min(0.05, …) を忘れると、致命的なバグが起きます。
タブを裏に回して30秒後に戻ってくると、dt が 30 になります。すると player.speedX * dt で一気に90タイル移動——壁を貫通してマップ外へワープ。判定が「動く前と後」しか見ていないので、間にある壁を全部飛び越えてしまうのです。これをトンネリングと言います。
上限0.05秒(=20fps相当)を付けておけば、どんなにコマ落ちしても1コマの移動は小さく保たれます。フレーム時間には必ず上限を。すべてのゲームループの鉄則です。
スマブラ編との違い:dt を使うか、コマ数を使うか
スマブラ編では「1コマで4.4px動く」と書きました。今回は「1秒で3.15タイル動く」です。
- コマ基準:
x += 4.4→ シンプル。でも高リフレッシュレート(144Hz)のモニターだと2.4倍速くなる - 時間基準(dt):
x += 3.15 * dt→ どんな環境でも同じ速さ
3Dで動き回るFPSでは、環境によって速度が変わると致命的です。リアルタイム性が重要なゲームでは dt 方式、と覚えておきましょう。
更新の順番
function update(dt) {
time += dt;
updateTimers(dt); // 各種カウントダウン
updatePlayerMovement(dt); // WASD・ジャンプ・スライディング
updateAimAndShooting(dt); // 構える・撃つ・反動
updateWorld(dt); // 敵・リング・煙・ドローンなど
updateShortLivedThings(dt); // 火花やダメージ数字の寿命
updateOtherSquads(dt); // 画面外の戦い
findNearbyLootBox(); // Eキーで漁れる箱を探す
if (alive <= 1 && !player.isDead) endGame(true); // 勝利判定
}
update の中身が7行の関数呼び出しだけになっているのに注目してください。中身は合計数百行ありますが、ここを見れば「1コマで何が起きるか」が一目で分かります。
大きな関数は、名前の付いた小さな関数を並べたリストにする。これができていると、バグを追うとき「どの段階の問題か」がすぐ絞り込めます。
そして配列から要素を消すときは、必ず後ろから。
for (let i = effects.length - 1; i >= 0; i--) {
effects[i].t += dt;
if (effects[i].t >= effects[i].life) effects.splice(i, 1);
}
前から消すと、splice で番号がずれて次の要素を飛ばしてしまいます。「消すループは後ろから」は、丸暗記していい鉄則です。
動かしてみよう
ファイルを保存したら、index.html をダブルクリックしてブラウザで開いてみましょう。
- レジェンドを選んで「降下する」をクリック
- 画面をクリックしてマウスを固定(ポインタロック)
- 建物を見つけて E で物資を漁り、装備を整える
- リングの中に留まりながら、敵を倒して最後の1人を目指す
操作方法
キー | 動作 |
|---|---|
WASD | 移動 |
マウス | 視点 |
左クリック | 射撃 |
右クリック | エイム(構える) |
Shift | ダッシュ |
Ctrl | しゃがみ・スライディング |
Space | ジャンプ |
R | リロード |
1・2・ホイール | 武器切替 |
E | 物資を漁る |
3 / 4 | 体力回復 / シールド回復 |
Q / Z | 戦術アビリティ / アルティメット |
ESC / M | ポーズ / BGM切替 |
もし動かない・エラーが出る場合は以下をチェック:
- ファイルが同じフォルダに入っているか?
- ファイル名は
index.html、style.css、script.jsか? - 開発者ツール(F12)の Console タブにエラーが出ていないか?
- マウスで視点が動かない場合、一度キャンバスをクリックしたか?(ポインタロックの許可が必要)
- 音が出ない場合、一度キーを押すかクリックしたか?(ブラウザは操作前に音を鳴らせません)
- 動作が重い場合は
RESOLUTION_SCALEを 0.5 に、COLUMN_WIDTHを 3 にしてみましょう
次のステップ
動いたら、自分なりに改造して遊んでみよう。「数字を変える → データを足す → 仕組みを作る」の順に攻めるのがおすすめです。
🟢 簡単(定数をいじるだけ)
spawnBots(18)の数を変えて、敵の密度を調整するRING_STAGESのdpsを上げて、リング外が即死級の高速マッチにWALK_SPEEDやSPRINT_SPEEDを上げて、超スピードバトルにGRAVITYを小さく・JUMP_SPEEDを大きくして、月面ジャンプにMAP_W/MAP_Hを 96 にして、広大なマップで戦う- クレーバーの
damage: 145を変えて、一撃必殺の調整をする
🟡 中くらい(データを足す)
WEAPONSにオリジナルの武器を追加する(表に1つ足すだけ!)LEGENDSに5人目のレジェンドを追加するtexCanvasに新しい壁模様を追加して、マップの雰囲気を変えるrollLootの確率を変えて、「武器がなかなか出ない」サバイバル寄りにARMOR_LEVELSを増やして、さらに上のレア度を作る
🔴 むずかしい(仕組みを作る)
- ドアを実装する(開閉できるタイル。Apexらしさが一気に上がる)
- ジップラインやジャンプパッドでマップに立体感を出す
- 敵に経路探索(A*)を実装して、壁を回り込んで追ってくるようにする
- 投げ物(グレネード)を追加する(放物線を飛ぶオブジェクト)
- 降下フェーズを作る(空から落下地点を選ぶ、あの導入部分)
- 天井と床にもテクスチャを貼る(フロアキャスティング)
wallDepthを活かして、霧の表現を足す
学べることが盛りだくさん
このゲーム1本で、こんなことが学べます:
- レイキャスティングによる疑似3D描画(DDA法・魚眼補正・テクスチャマッピング)
- 深度バッファと画家のアルゴリズムによる前後関係の処理
- ベクトルの内積・外積・正規化という、3Dの基礎になる数学
- ヒットスキャンの当たり判定と、ヘッドショット・距離減衰の設計
- 状態を持つ敵AI(視認・記憶・追跡・回避行動・スタック脱出)
- 棄却法によるランダムマップ生成
dtベースのゲームループと、トンネリングの防ぎ方- ポインタロックによる本格的なFPS操作
- Web Audio APIによる効果音の合成
- 「見えないところは手を抜く」という、ゲーム設計の割り切り
分からないことがあったら「JavaScript レイキャスティング」「Canvas raycasting」で検索してみよう。
レイキャスティングは1992年の技術ですが、「難しい問題を、小さなくり返しに分解する」という考え方は30年経ってもまったく色褪せません。この記事で作ったものは、あなたが今後どんなゲームを作るときにも効いてくる基礎体力です。
64×64の数字の表から、遊べる3Dの世界を作り出せた——それができた時点で、あなたはもう立派なゲームクリエイターです!
完成コード全文
index.html と style.css は完成版がこの記事のフォルダに入っています。 script.js は約4200行あるので、ここではファイルのダウンロードリンクを置きます。
全行に日本語コメントが付いているので、ダウンロードして読みながら写経するのもおすすめです。特に drawWalls() の1関数だけを写経して動かしてみると、3Dが立ち上がる瞬間の感動が味わえます。