ついに完成しました。長らくブログで開発を続けてきた「ブラウザで動くシューティングゲーム」ですが、この度、大幅なアップデートを経てVer 2.93へと進化を遂げました。

これまでのバージョン(Ver 1.0〜1.5)では、基本的な「撃つ」「避ける」という楽しさを追求してきましたが、今回は一味違います。以前作成した「テトリス」のモダンなUIデザインを取り入れ、スマホやタブレットでの快適な操作性を実現し、さらにWeb Audio APIを駆使して「最新のiPadでもサクサク動く」レベルまで最適化を行いました。
この記事では、Ver 1.5からどのように問題を解決し、Ver 2.93へと至ったのか、その紆余曲折のプロセスと、完成した全ソースコードを余すことなく公開します。プログラミング初心者の方も、ブラウザゲーム開発に興味がある方も、ぜひ最後までお付き合いください。

PCブラウザ版での操作方法はこれです↓
左右の矢印キーで機体を左右に動かせるよ!スペースキーでミサイル発射!!
最初に「Click to Start」ボタンを押してね。
スマホ版はゲーム下のボタンで操作ですよ!左右の矢印をスワイプすることで機体を操作するぞ!
Ver 1.5からの挑戦:モダンUIとランキング機能の統合
これまでのシューティングゲーム開発を振り返ると、Ver 1.5までは「とにかく動くこと」を最優先にしていました。しかし、時代は令和。ただ動くだけではなく、見た目のカッコよさや、世界中のプレイヤーと競える機能が欲しくなってきました。そこで、以前このブログで作成した「テトリス」の知見を活かすことにしたのです。
テトリスから学ぶ「ネオン風デザイン」の移植
まず着手したのは、画面全体のデザイン刷新です。Ver 1.5までの黒背景にシンプルな文字だけという硬派なスタイルも悪くはありませんでしたが、少し寂しさがありました。そこで、テトリス開発時に好評だった「ネオン風デザイン」を採用することにしました。
CSS変数を活用し、--neon-blue: #00f3ff; や --neon-pink: #ff0055; といった発光感のある色を定義。これをボタンの枠線やテキストの影(text-shadow)、ボックスの影(box-shadow)に適用することで、サイバーパンクな雰囲気を演出しました。
また、画面レイアウトもPCでは「ランキング・ゲーム画面・ステータス」の3カラム構成にし、スマホでは縦積みに自動で切り替わるレスポンシブデザインを導入。これにより、どんなデバイスで見ても「それっぽい」ゲーム画面を作ることに成功しました。
Google Apps Script (GAS) を使ったランキングシステム
ゲームのモチベーション維持に欠かせないのが「ランキング機能」です。サーバーサイドの難しい知識がなくても実装できるよう、Google Apps Script(GAS)を利用した簡易ランキングシステムを組み込みました。
仕組みは至ってシンプルです。
- ゲーム終了時、スコアとプレイヤー名をJavaScriptの
fetch関数でGASのウェブアプリURLに送信(POST)。 - GAS側で受け取ったデータをGoogleスプレッドシートに保存・ソート。
- ゲーム開始時やリロード時に、GASから上位のデータを取得(GET)して表示。
この仕組みをクラス設計(RankingManagerクラス)として独立させることで、コードの可読性を保ちつつ、他のゲームにも流用できるような設計にしました。これで、自分のハイスコアが世界に刻まれる快感を味わえます。
クラスベース設計への全面リファクタリング
Ver 1.5までは、すべての変数や関数がグローバル(大域)に散らばっている、いわゆる「スパゲッティコード」に近い状態でした。機能が増えるにつれ、どこを直せばいいのか分からなくなるのが目に見えていました。
そこで、Ver 2.0へのアップデートを機に、以下のクラスに機能を分割・整理しました。
- Gameクラス: ゲームの進行、ループ処理、全体の状態管理。
- Playerクラス: 自機の移動、描画、無敵時間の管理。
- Enemyクラス: 敵の生成、移動パターン、描画。
- Bulletクラス: 弾の移動、当たり判定の基礎。
- RankingManagerクラス: ランキングの送受信、DOM操作。
- AudioControllerクラス: BGMや効果音の管理。
このように役割分担を明確にすることで、例えば「敵の動きを変えたい」ときはEnemyクラスだけを見れば良くなり、開発効率が劇的に向上しました。これが後のタブレット対応での修正にも大きく役立つことになります。
スマホ・タブレット操作への最適化と苦悩
PCのキーボード操作は比較的簡単に実装できましたが、最大の壁は「タッチデバイスでの操作性」でした。Ver 1.5の時点でもスマホ対応はしていましたが、Ver 2.0以降ではより直感的で、かつ誤操作の少ないUIを目指して試行錯誤を繰り返しました。
画面からはみ出すUI問題とCSSの戦い
最初の壁は、デバイスごとの画面サイズの違いでした。特にAndroid端末(Xiaomi 13T Proなど)で確認した際、ゲーム画面が縦に長すぎて、操作ボタンが画面外に押し出されてしまう現象が発生しました。
これを解決するために導入したのが、calc()関数やvh(ビューポートの高さ)単位を駆使したCSS設計です。
- ゲーム画面(Canvas)の高さを
max-height: 50vh;(画面の半分の高さ)に制限。 - 操作ボタンエリアをフレックスボックスでレイアウトし、残りのスペースに収まるように調整。
overflow: hidden;をbodyに適用し、不意なスクロールを完全に禁止。
これにより、どんな縦長・横長のスマホでも、ゲーム画面と操作ボタンが一画面にきれいに収まる「アプリのようなUI」を実現しました。
感度調整の泥沼:デジタルからアナログ操作へ
操作性の調整には本当に苦労しました。当初は画面上の矢印ボタンをタップする方式でしたが、アクションゲームとしてはいまいち直感的ではありません。そこで、指をスライドさせて操作する「スワイプ操作」を導入しました。
しかし、最初は「少し指が動いただけで全速力で移動する」という過敏すぎる挙動になり、微調整ができないという問題が発生。そこで、Ver 2.7にて「アナログスティック方式」を採用しました。
JavaScript
// アナログ値計算 (-1.0 ~ 1.0)
let force = diff / sensitivity;
// デッドゾーン (微小な指の震えを無視)
if (Math.abs(force) < 0.15) force = 0;
このように、指の移動距離に応じて移動スピードを 0.1 から 1.0 まで可変させることで、ゆっくり動かしたい時は指を少しだけ、急いで逃げたい時は大きく動かすという、家庭用ゲーム機のコントローラーに近い操作感を実現しました。
タブレット対応:物理キーなし問題の解決
スマホ対応が完了してホッとしたのも束の間、iPadなどのタブレット端末でテストした際に新たな問題が発覚しました。PC版の表示(3カラムレイアウト)になるものの、物理キーボードがないため操作不能になってしまったのです。
これに対応するため、Ver 2.8では「PC・タブレット表示時でも、画面下部にタッチ操作用のボタンをオーバーレイ表示する」仕様に変更しました。CSSのメディアクエリを調整し、画面幅が広い場合でも .controls クラスを display: none にせず、position: fixed で画面下中央に配置。これで、iPadの大画面でもタッチ操作で快適に遊べるようになりました。
ラスボス登場:iPadでの「弾を撃つと重くなる」問題
すべての機能が出揃い、完璧だと思われたVer 2.9。しかし、最新のiPad Airでテストプレイをした際、信じられない現象に遭遇しました。「弾を連射すればするほど、画面がカクつき、ガクガクになる」 のです。PCやスマホでは問題ないのに、なぜ高性能なiPadで?
原因は「メモリの大量消費」と「ガベージコレクション」
調査の結果、原因は2つありました。
- オブジェクトの生成・破棄: 弾を撃つたびに
new Bullet()をし、画面外に出たら破棄する。これを高速で行うと、ブラウザの「ゴミ掃除(ガベージコレクション)」が頻繁に走り、処理落ちを引き起こしていました。 - HTML5 Audioの限界: 効果音を鳴らすたびに
new Audio()を生成していたため、メモリを食いつぶし、ブラウザの描画処理を阻害していました。
解決策1:オブジェクトプールの導入
まず、弾や敵、爆発エフェクトの生成処理を根本から変えました。ゲーム開始時にあらかじめ大量のオブジェクト(弾なら50個など)を作っておき、それらを使い回す「オブジェクトプール」という手法です。
JavaScript
// プールから非アクティブな弾を探して発射
const bullet = this.bullets.find(b => !b.active);
if (bullet) {
bullet.activate(x, y);
// ...
}
これにより、プレイ中のメモリ確保・破棄がほぼゼロになり、CPU負荷が劇的に下がりました。
解決策2:Web Audio APIへの刷新
決定打となったのが音声処理の変更です。従来の <audio> タグ方式から、プロのWebゲームでも使われる Web Audio API へと移行しました。
Web Audio APIは、音声をメモリ上にデコードして保持し、専用のオーディオコンテキストで再生するため、連射してもCPU負荷がほとんどかかりません。さらに、Web Audio APIが使えない環境のために、自動で旧方式(ただし間引き処理付き)に切り替わるフォールバック機能も実装。
これにより、どれだけ弾幕を張っても、iPad Airで60FPSヌルヌルの動作を実現することができました。これが今回の Ver 2.93 の正体です。
【完全公開】シューティングゲーム Ver 2.93 ソースコード
お待たせしました。これまでのノウハウをすべて詰め込んだ、Ver 2.93の全ソースコードを公開します。
このコードをコピーして、index.html という名前で保存し、ブラウザで開くだけで遊べます。(※ランキング機能を使うにはGASの設定が別途必要ですが、設定しなくてもゲーム自体は動きます)
ソースコード
HTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Shooting Game Ver 2.93</title>
<style>
:root {
--bg-color: #050510;
--ui-color: #fff;
--accent-color: #4a4a55;
--panel-bg: rgba(20, 20, 30, 0.95);
--btn-bg: rgba(255, 255, 255, 0.15);
--btn-active: rgba(255, 255, 255, 0.4);
--rank-gold: #ffd700;
--rank-silver: #c0c0c0;
--rank-bronze: #cd7f32;
--neon-blue: #00f3ff;
--neon-pink: #ff0055;
--neon-green: #0aff00;
}
* {
box-sizing: border-box;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
body {
background-color: var(--bg-color);
color: var(--ui-color);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
overflow: hidden; /* スクロール禁止 */
touch-action: none;
}
/* レイアウト */
.game-wrapper {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 20px;
padding: 10px;
width: 100%;
max-width: 900px;
position: relative;
}
/* 左パネル:ランキング */
.ranking-panel {
width: 220px;
background: var(--panel-bg);
border: 2px solid var(--neon-blue);
box-shadow: 0 0 15px rgba(0, 243, 255, 0.15);
border-radius: 8px;
padding: 0;
display: flex;
flex-direction: column;
height: 500px;
overflow: hidden;
position: relative;
order: 1;
}
.ranking-header {
background: rgba(0, 243, 255, 0.1);
padding: 10px 0;
border-bottom: 1px solid var(--neon-blue);
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
.ranking-title {
font-size: 1rem;
color: var(--neon-blue);
text-transform: uppercase;
font-weight: bold;
letter-spacing: 1px;
text-shadow: 0 0 5px var(--neon-blue);
margin: 0;
}
#reload-ranking {
background: transparent;
border: 1px solid var(--neon-blue);
color: var(--neon-blue);
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
#ranking-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex-grow: 1;
font-size: 0.85rem;
}
#ranking-list::-webkit-scrollbar { width: 4px; }
#ranking-list::-webkit-scrollbar-thumb { background: var(--neon-blue); border-radius: 2px; }
.rank-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.rank-item.highlight { background: rgba(255, 0, 85, 0.4); }
.rank-pos {
width: 20px; text-align: center; font-weight: bold;
background: rgba(255,255,255,0.1); border-radius: 4px; margin-right: 5px; font-size: 0.8rem;
}
.rank-name { flex-grow: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 5px; color: #eee; }
.rank-score { text-align: right; color: var(--neon-green); font-family: monospace; font-weight: bold; }
.rank-item:nth-child(1) .rank-pos { color: #000; background: var(--rank-gold); }
.rank-item:nth-child(2) .rank-pos { color: #000; background: var(--rank-silver); }
.rank-item:nth-child(3) .rank-pos { color: #000; background: var(--rank-bronze); }
/* 中央:ゲームコンテナ */
.game-container {
position: relative;
border: 2px solid var(--accent-color);
box-shadow: 0 0 20px rgba(0,0,0,0.5);
background: #000;
/* 480x600の比率 (4:5) */
width: 400px;
aspect-ratio: 4 / 5;
flex-shrink: 0;
order: 2;
}
canvas {
display: block;
background-color: #000;
width: 100%;
height: 100%;
object-fit: contain;
/* ★タブレット等の高解像度対策: 補間処理を無効化して負荷を下げる */
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* 右パネル:ステータス */
.side-panel {
display: flex;
flex-direction: column;
gap: 15px;
width: 120px;
order: 3;
}
.stats-container {
background: var(--panel-bg);
border: 2px solid var(--accent-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.label { font-size: 0.8rem; color: #aaa; margin-bottom: 2px; text-transform: uppercase; }
.stat-item { background: rgba(255,255,255,0.05); padding: 8px; border-radius: 4px; border-left: 3px solid var(--neon-blue); }
.stat-value { font-size: 1.2rem; font-weight: bold; font-family: monospace; }
#score { color: var(--neon-green); }
#level { color: var(--neon-blue); }
#lives { color: var(--neon-pink); }
/* オーバーレイ */
#overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex; flex-direction: column; justify-content: center; align-items: center;
z-index: 10; padding: 20px; text-align: center;
backdrop-filter: blur(2px);
}
#overlay h1 { font-size: 2.2rem; margin: 0 0 10px 0; color: var(--neon-blue); text-shadow: 0 0 10px var(--neon-blue); }
#status-message { font-size: 1.2rem; margin-bottom: 20px; min-height: 1.5em; color: #ddd; }
/* 入力フォーム */
#ranking-form {
display: none; flex-direction: column; gap: 10px; width: 100%; max-width: 200px; margin-bottom: 20px;
}
#player-name {
padding: 10px; border-radius: 4px; border: 1px solid var(--neon-blue); background: #333; color: #fff;
text-align: center; font-size: 1rem; outline: none;
}
#submit-score {
padding: 10px; background: var(--neon-pink); color: white; border: none; border-radius: 4px;
cursor: pointer; font-weight: bold; font-size: 1rem; transition: background 0.2s;
box-shadow: 0 0 10px rgba(255, 0, 85, 0.3);
}
#submit-score:hover { background: #d00045; }
#submit-score:disabled { background: #555; cursor: not-allowed; }
#restart-btn {
padding: 12px 30px; background: transparent; border: 2px solid var(--neon-green); color: var(--neon-green);
border-radius: 25px; cursor: pointer; font-size: 1.2rem; font-weight: bold;
text-shadow: 0 0 5px var(--neon-green); box-shadow: 0 0 5px var(--neon-green);
transition: all 0.2s;
}
#restart-btn:hover { background: rgba(10, 255, 0, 0.1); transform: scale(1.05); }
/* ローディング */
#ranking-loading-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(32, 32, 40, 0.7);
display: none;
flex-direction: column; justify-content: center; align-items: center;
z-index: 10; backdrop-filter: blur(1px);
}
.loading-band {
width: 100%; padding: 10px 0; background: rgba(0, 0, 0, 0.8);
border-top: 1px solid var(--neon-blue); border-bottom: 1px solid var(--neon-blue);
display: flex; justify-content: center; align-items: center; gap: 10px;
}
.loading-spinner {
width: 16px; height: 16px; border: 2px solid rgba(0, 243, 255, 0.3);
border-top: 2px solid var(--neon-blue); border-radius: 50%; animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* モバイル対応要素 */
#mobile-ranking-btn {
display: none;
margin-top: 5px;
padding: 8px 20px;
background: var(--panel-bg);
border: 2px solid var(--neon-blue);
color: var(--neon-blue);
border-radius: 25px;
font-weight: bold;
font-size: 0.9rem;
cursor: pointer;
box-shadow: 0 0 10px rgba(0, 243, 255, 0.2);
}
.ranking-modal {
display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); z-index: 50; justify-content: center; align-items: center; backdrop-filter: blur(3px);
}
.ranking-modal-content {
width: 90%; max-width: 350px; height: 500px; background: var(--panel-bg);
border: 2px solid var(--neon-blue); border-radius: 8px; display: flex; flex-direction: column; position: relative;
}
.close-modal {
position: absolute; top: 5px; right: 10px; color: #fff; font-size: 2rem; cursor: pointer; z-index: 2;
}
/* コントローラー (共通設定: PC/タブレット/スマホで表示) */
.controls {
display: flex;
/* PC/タブレット: 画面下部に固定してオーバーレイ表示 */
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 500px; /* PCでの広がりすぎ防止 */
padding: 0 20px;
justify-content: space-between;
align-items: center;
z-index: 20;
pointer-events: none; /* ボタン以外はタッチ透過 */
}
/* 左右ボタンのコンテナ - 統合 */
.controls-left {
display: flex;
gap: 10px;
pointer-events: auto;
flex-grow: 1; /* スペースを埋める */
max-width: 250px; /* 最大幅 */
margin-right: 20px;
}
/* 共通ボタン基本スタイル */
.control-btn {
border-radius: 50%;
display: flex; justify-content: center; align-items: center;
pointer-events: auto;
backdrop-filter: blur(2px);
user-select: none;
touch-action: none;
color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
}
.control-btn:active, .control-btn.active {
transform: scale(0.95);
}
/* 移動用スライダーパッド (新設) */
#btn-move {
width: 100%;
height: 70px;
border-radius: 35px; /* カプセル型 */
background: rgba(255, 255, 255, 0.15);
border: 2px solid rgba(255, 255, 255, 0.5);
font-size: 32px;
font-weight: bold;
display: flex;
justify-content: space-between;
padding: 0 25px;
}
#btn-move:active, #btn-move.active {
background: rgba(255, 255, 255, 0.3);
}
/* ショットボタン */
#btn-fire {
width: 80px; height: 80px;
font-size: 20px; font-weight: bold;
background: rgba(255, 0, 85, 0.4);
border: 2px solid var(--neon-pink);
flex-shrink: 0;
}
#btn-fire:active { background: rgba(255, 0, 85, 0.7); }
.move-area {
position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5;
display: none; /* JSでタッチデバイス判定時に有効化 */
}
/* レスポンシブ - スマホ向けレイアウト調整 */
@media (max-width: 768px) {
body {
position: fixed; /* 画面固定 */
width: 100%;
height: 100%;
overflow: hidden;
}
.game-wrapper {
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 5px;
height: 100dvh;
padding: 10px;
flex-wrap: nowrap;
}
.ranking-panel { display: none; }
.ranking-modal .ranking-panel { display: flex; width: 100%; height: 100%; border: none; box-shadow: none; }
#mobile-ranking-btn { display: block; }
.game-container {
order: 1;
/* 画面の50% */
height: 50vh;
width: auto;
aspect-ratio: 4 / 5;
flex-shrink: 1;
margin-bottom: 5px;
}
.side-panel {
order: 2;
flex-direction: row;
width: 100%;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 10px;
}
.stats-container {
flex-direction: row;
gap: 10px;
padding: 5px 10px;
width: auto;
flex-grow: 1;
justify-content: space-around;
}
/* スマホではレイアウトの流れに組み込む */
.controls {
position: relative;
bottom: auto;
left: auto;
transform: none;
order: 3;
width: 100%;
max-width: 500px;
margin-top: 10px;
padding-bottom: 15px;
pointer-events: none;
}
.move-area { display: block; }
}
</style>
</head>
<body>
<div class="game-wrapper">
<!-- Ranking Panel -->
<div class="ranking-panel" id="pc-ranking-panel">
<div class="ranking-header">
<div class="ranking-title">Ranking</div>
<button id="reload-ranking" title="Reload">↻</button>
</div>
<ul id="ranking-list">
<li style="padding:10px;text-align:center;color:#aaa;">Loading...</li>
</ul>
<div id="ranking-loading-overlay">
<div class="loading-band">
<div class="loading-spinner"></div>
<span class="loading-text">Loading...</span>
</div>
</div>
</div>
<!-- Game Container -->
<div class="game-container">
<canvas id="gameCanvas" width="480" height="600"></canvas>
<!-- タッチ用移動エリア (Canvasの上に透明配置) -->
<div class="move-area" id="touch-area"></div>
<div id="overlay">
<h1 id="title-text">SHOOTING<br><span style="font-size:0.6em;color:#fff;">Ver 2.93</span></h1>
<p id="status-message">Ready?</p>
<div id="ranking-form">
<input type="text" id="player-name" placeholder="Name (max 10)" maxlength="10">
<button id="submit-score">Send Score</button>
</div>
<button id="restart-btn">START MISSION</button>
</div>
</div>
<!-- Side Panel -->
<div class="side-panel">
<div class="stats-container">
<div class="stat-item">
<div class="label">Score</div>
<div id="score" class="stat-value">0</div>
</div>
<div class="stat-item">
<div class="label">Level</div>
<div id="level" class="stat-value">1</div>
</div>
<div class="stat-item">
<div class="label">HP</div>
<div id="lives" class="stat-value">3</div>
</div>
</div>
<button id="mobile-ranking-btn">🏆 RANKING</button>
</div>
<!-- Mobile Controls -->
<div class="controls">
<div class="controls-left">
<!-- 統合された移動ボタン -->
<div class="control-btn" id="btn-move">
<span>←</span><span>→</span>
</div>
</div>
<div class="control-btn" id="btn-fire">Shot</div>
</div>
</div>
<!-- Mobile Ranking Modal -->
<div class="ranking-modal" id="ranking-modal">
<div class="ranking-modal-content">
<div class="close-modal" id="close-modal">×</div>
<div id="modal-ranking-container" style="width:100%; height:100%;"></div>
</div>
</div>
<script>
// ==========================================
// CONFIGURATION
// ==========================================
// ★ここにランキング用GASのURLを貼り付けてください★
const RANKING_API_URL = "";
const ASSETS = {
ship: 'https://tokodomo.xyz/wp-content/uploads/2024/07/jiki.png',
enemy: 'https://tokodomo.xyz/wp-content/uploads/2024/07/teki.png',
explosion1: 'https://tokodomo.xyz/wp-content/uploads/2024/07/shooting_ver1.4_bom1.png',
explosion2: 'https://tokodomo.xyz/wp-content/uploads/2024/07/shooting_ver1.4_bom2.png',
bg: 'https://tokodomo.xyz/wp-content/uploads/2025/12/tetris_back_01.jpg'
};
const SOUND_URLS = {
bgm: "https://tokodomo.xyz/wp-content/uploads/2024/09/Fall-of-the-Blocks.mp3",
shot: "https://tokodomo.xyz/wp-content/uploads/2024/09/rotate.mp3",
hit: "https://tokodomo.xyz/wp-content/uploads/2024/09/drop.mp3",
explosion: "https://tokodomo.xyz/wp-content/uploads/2024/09/line-clear.mp3",
gameover: "https://tokodomo.xyz/wp-content/uploads/2024/09/tetris.mp3"
};
// ==========================================
// UTILITIES & MANAGERS
// ==========================================
class AudioController {
constructor() {
this.ctx = null;
this.buffers = {};
this.isMuted = false;
this.loaded = false;
// Fallback for old/limited environment
this.fallbackPool = {};
this.useWebAudio = true;
this.lastPlayed = {};
}
init() {
if (this.loaded) return;
// Web Audio APIの初期化を試みる
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
this.ctx = new AudioContext();
this.loadAllWebAudio();
} else {
throw new Error("Web Audio API not supported");
}
} catch (e) {
console.log("Fallback to HTML5 Audio");
this.useWebAudio = false;
this.initFallback();
}
this.loaded = true;
}
async loadAllWebAudio() {
// 音声ファイルのプリロード (Web Audio)
const promises = Object.keys(SOUND_URLS).map(async key => {
const url = SOUND_URLS[key];
if (!url) return;
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer);
this.buffers[key] = audioBuffer;
} catch (e) {
console.warn(`Web Audio load failed for ${key}:`, e);
}
});
// BGMはHTML5 Audio要素でストリーミング再生する(ロード待ちしないため)
if (SOUND_URLS.bgm) {
this.bgm = new Audio(SOUND_URLS.bgm);
this.bgm.loop = true;
this.bgm.volume = 0.3;
}
}
initFallback() {
// 従来のプール方式(Web Audioが使えない場合用)
if(SOUND_URLS.bgm) {
this.bgm = new Audio(SOUND_URLS.bgm);
this.bgm.loop = true;
this.bgm.volume = 0.3;
}
Object.keys(SOUND_URLS).forEach(key => {
if (key === 'bgm') return;
const url = SOUND_URLS[key];
if (!url) return;
this.fallbackPool[key] = [];
this.lastPlayed[key] = 0;
for(let i=0; i<5; i++) {
const a = new Audio(url);
a.volume = 0.4;
a.preload = 'auto';
this.fallbackPool[key].push(a);
}
});
}
play(name) {
if (this.isMuted) return;
// Web Audio API ルート (軽量・高速)
if (this.useWebAudio && this.ctx && this.buffers[name]) {
// コンテキストが停止していたら再開 (iOS対策)
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
const source = this.ctx.createBufferSource();
source.buffer = this.buffers[name];
const gainNode = this.ctx.createGain();
gainNode.gain.value = 0.4;
source.connect(gainNode);
gainNode.connect(this.ctx.destination);
source.start(0);
return;
}
// Fallback ルート (従来方式・間引き付き)
if (!this.useWebAudio && this.fallbackPool[name]) {
const now = Date.now();
if (now - this.lastPlayed[name] < 60) return;
this.lastPlayed[name] = now;
const availableAudio = this.fallbackPool[name].find(a => a.paused || a.ended);
if (availableAudio) {
availableAudio.currentTime = 0;
availableAudio.play().catch(()=>{});
} else {
const oldest = this.fallbackPool[name].reduce((p, c) => p.currentTime > c.currentTime ? p : c);
oldest.currentTime = 0;
oldest.play().catch(()=>{});
}
}
}
playBGM() {
if (this.isMuted || !this.bgm) return;
// Web Audio APIモードでもBGMはAudio要素を使う(ループ制御が楽なため)
if (this.useWebAudio && this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
this.bgm.play().catch(()=>{});
}
stopBGM() {
if (this.bgm) {
this.bgm.pause();
this.bgm.currentTime = 0;
}
}
}
const audio = new AudioController();
class RankingManager {
constructor() {
this.form = document.getElementById('ranking-form');
this.input = document.getElementById('player-name');
this.submitBtn = document.getElementById('submit-score');
this.list = document.getElementById('ranking-list');
this.reloadBtn = document.getElementById('reload-ranking');
this.loadingOverlay = document.getElementById('ranking-loading-overlay');
this.lastSubmitName = "";
this.submitBtn.addEventListener('click', () => this.submitScore());
this.reloadBtn.addEventListener('click', () => this.fetchRanking());
// モーダル対応
this.pcPanel = document.getElementById('pc-ranking-panel');
this.modalContainer = document.getElementById('modal-ranking-container');
this.modal = document.getElementById('ranking-modal');
document.getElementById('mobile-ranking-btn').addEventListener('click', () => {
this.modal.style.display = 'flex';
this.modalContainer.appendChild(this.pcPanel);
});
document.getElementById('close-modal').addEventListener('click', () => {
this.modal.style.display = 'none';
document.querySelector('.game-wrapper').insertBefore(this.pcPanel, document.querySelector('.game-container'));
});
}
async fetchRanking() {
if (!RANKING_API_URL) {
this.list.innerHTML = '<li style="padding:10px;text-align:center;color:#ff5555;">URL未設定</li>';
return;
}
this.loadingOverlay.style.display = 'flex';
try {
let cleanUrl = RANKING_API_URL.trim().replace(/\?$/, '');
const url = `${cleanUrl}?action=get&t=${new Date().getTime()}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'success') {
this.renderList(data.ranking);
}
} catch (e) {
this.list.innerHTML = '<li style="padding:10px;text-align:center;">Load Failed</li>';
} finally {
this.loadingOverlay.style.display = 'none';
}
}
async submitScore() {
if (!RANKING_API_URL) return;
const name = this.input.value.trim() || "Pilot";
const score = game.score;
this.lastSubmitName = name;
this.submitBtn.disabled = true;
this.submitBtn.innerText = "Sending...";
try {
let cleanUrl = RANKING_API_URL.trim().replace(/\?$/, '');
const formData = new URLSearchParams();
formData.append('action', 'save');
formData.append('name', name);
formData.append('score', score);
await fetch(cleanUrl, { method: 'POST', body: formData, mode: 'no-cors' });
this.form.style.display = 'none';
ui.status.innerText = "Score Sent!";
setTimeout(() => this.fetchRanking(), 2000);
} catch (e) {
alert("送信エラー");
} finally {
this.submitBtn.disabled = false;
this.submitBtn.innerText = "Send Score";
}
}
renderList(rankingData) {
this.list.innerHTML = '';
if (!rankingData || rankingData.length === 0) {
this.list.innerHTML = '<li style="padding:10px;text-align:center;">No Data</li>';
return;
}
rankingData.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'rank-item';
if (this.lastSubmitName === item.name && game.score == item.score) li.classList.add('highlight');
li.innerHTML = `<span class="rank-pos">${index + 1}</span><span class="rank-name">${this.escapeHtml(item.name)}</span><span class="rank-score">${item.score}</span>`;
this.list.appendChild(li);
});
}
showForm() { if(RANKING_API_URL) this.form.style.display = 'flex'; }
hideForm() { this.form.style.display = 'none'; }
escapeHtml(str) { return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
}
const ranking = new RankingManager();
const ui = {
score: document.getElementById('score'),
level: document.getElementById('level'),
lives: document.getElementById('lives'),
overlay: document.getElementById('overlay'),
title: document.getElementById('title-text'),
status: document.getElementById('status-message'),
restartBtn: document.getElementById('restart-btn')
};
// ==========================================
// GAME ENTITIES (オブジェクトプール対応)
// ==========================================
class Bullet {
constructor() {
this.width = 6;
this.height = 16;
this.speed = 12;
this.active = false;
this.x = 0;
this.y = 0;
}
// 再利用時に呼び出す
activate(x, y) {
this.x = x;
this.y = y;
this.active = true;
}
update() {
if (!this.active) return;
this.y -= this.speed;
if (this.y < -this.height) this.active = false;
}
draw(ctx) {
if (!this.active) return;
ctx.fillStyle = "#00f3ff";
ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
class Enemy {
constructor() {
this.width = 40;
this.height = 40;
this.active = false;
this.x = 0;
this.y = 0;
this.speedY = 0;
this.speedX = 0;
this.img = null;
}
spawn(canvasWidth, level, img) {
this.x = Math.random() * (canvasWidth - this.width);
this.y = -this.height;
const baseSpeed = 2 + (level * 0.5);
this.speedY = baseSpeed + Math.random();
this.speedX = (Math.random() - 0.5) * (level * 0.5);
this.active = true;
this.img = img;
}
update(canvasHeight, canvasWidth) {
if (!this.active) return;
this.y += this.speedY;
this.x += this.speedX;
if(this.x < 0 || this.x > canvasWidth - this.width) this.speedX *= -1;
if (this.y > canvasHeight) this.active = false;
}
draw(ctx) {
if (!this.active) return;
if (this.img) {
ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
} else {
ctx.fillStyle = "red";
ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
}
class Explosion {
constructor() {
this.active = false;
this.x = 0;
this.y = 0;
this.size = 0;
this.frame = 0;
this.maxFrames = 16;
this.img = null;
}
spawn(x, y, size, img) {
this.x = x;
this.y = y;
this.size = size;
this.img = img;
this.frame = 0;
this.active = true;
}
update() {
if (!this.active) return;
this.frame++;
this.y += 1;
if (this.frame >= this.maxFrames) this.active = false;
}
draw(ctx) {
if (!this.active) return;
if (this.img) {
ctx.globalAlpha = 1 - (this.frame / this.maxFrames);
ctx.drawImage(this.img, this.x - this.size/2, this.y - this.size/2, this.size, this.size);
ctx.globalAlpha = 1;
}
}
}
class Star {
constructor(w, h) {
this.x = Math.random() * w;
this.y = Math.random() * h;
this.size = Math.random() * 2;
this.speed = Math.random() * 3 + 0.5;
}
update(h, speedMult) {
this.y += this.speed * speedMult;
if (this.y > h) {
this.y = 0;
this.x = Math.random() * game.canvas.width;
}
}
draw(ctx) {
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI*2);
ctx.fill();
}
}
class Player {
constructor(canvas) {
this.canvas = canvas;
this.width = 50;
this.height = 50;
this.x = canvas.width / 2 - this.width / 2;
this.y = canvas.height - this.height - 20;
this.speed = 6;
this.img = game.images.ship;
this.invincible = 0;
}
move(input) {
// キーボード操作とスマホアナログ操作を合成
let force = 0;
// キーボード入力があればそれを優先 (デジタル: -1 or 1)
if (input.left) {
force = -1;
} else if (input.right) {
force = 1;
} else if (input.moveForce !== 0) {
// キー入力がなければスマホのアナログ入力を使用 (-1.0 ~ 1.0)
force = input.moveForce;
}
// 実際の移動
this.x += this.speed * force;
if (this.x < 0) this.x = 0;
if (this.x + this.width > this.canvas.width) this.x = this.canvas.width - this.width;
}
moveTo(targetX) {
const center = this.x + this.width / 2;
const diff = targetX - center;
this.x += diff * 0.2;
if (this.x < 0) this.x = 0;
if (this.x + this.width > this.canvas.width) this.x = this.canvas.width - this.width;
}
draw(ctx) {
if (this.invincible > 0) {
this.invincible--;
if (Math.floor(Date.now() / 50) % 2 === 0) return;
}
if (this.img) {
ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
} else {
ctx.fillStyle = "cyan";
ctx.beginPath();
ctx.moveTo(this.x + this.width/2, this.y);
ctx.lineTo(this.x, this.y + this.height);
ctx.lineTo(this.x + this.width, this.y + this.height);
ctx.fill();
}
}
}
// ==========================================
// MAIN GAME ENGINE
// ==========================================
class Game {
constructor() {
this.canvas = document.getElementById("gameCanvas");
this.ctx = this.canvas.getContext("2d");
this.images = {};
this.state = 'START';
this.score = 0;
this.level = 1;
this.lives = 3;
// ★追加: UI更新用キャッシュ
this.lastScore = -1;
this.lastLevel = -1;
this.lastLives = -1;
this.player = null;
this.stars = [];
// オブジェクトプール初期化 (重要: new を減らす)
this.bullets = [];
this.enemies = [];
this.explosions = [];
// プールサイズの設定 (足りなければ増やす)
for(let i=0; i<50; i++) this.bullets.push(new Bullet());
for(let i=0; i<30; i++) this.enemies.push(new Enemy());
for(let i=0; i<20; i++) this.explosions.push(new Explosion());
// moveForceを追加: -1.0(左) ~ 1.0(右) のアナログ値
this.input = { left: false, right: false, fire: false, moveForce: 0 };
this.touchX = null;
this.moveStartX = null; // スワイプ基準点
this.lastShotTime = 0;
this.loopId = null;
this.loadAssets();
this.setupInputs();
for(let i=0; i<80; i++) this.stars.push(new Star(this.canvas.width, this.canvas.height));
ranking.fetchRanking();
}
loadAssets() {
const names = Object.keys(ASSETS);
let loadedCount = 0;
names.forEach(name => {
const img = new Image();
img.src = ASSETS[name];
img.onload = () => {
this.images[name] = img;
loadedCount++;
};
img.onerror = () => { console.warn(`Failed to load ${name}`); };
});
}
setupInputs() {
// Keyboard
document.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') this.input.left = true;
if (e.key === 'ArrowRight') this.input.right = true;
if (e.key === 'z' || e.key === ' ') this.input.fire = true;
});
document.addEventListener('keyup', e => {
if (e.key === 'ArrowLeft') this.input.left = false;
if (e.key === 'ArrowRight') this.input.right = false;
if (e.key === 'z' || e.key === ' ') this.input.fire = false;
});
// Touch (Main Screen - Direct Follow)
const touchArea = document.getElementById('touch-area');
touchArea.addEventListener('touchstart', e => {
e.preventDefault();
this.touchX = this.getTouchPos(e);
}, {passive: false});
touchArea.addEventListener('touchmove', e => {
e.preventDefault();
this.touchX = this.getTouchPos(e);
}, {passive: false});
touchArea.addEventListener('touchend', e => {
e.preventDefault();
this.touchX = null;
});
// Mobile Buttons (Explicit)
const btnMove = document.getElementById('btn-move');
const btnFire = document.getElementById('btn-fire');
// Move Slider (Virtual Joystick) with Multi-touch support
let moveTouchId = null;
const sensitivity = 60;
btnMove.addEventListener('touchstart', (e) => {
e.preventDefault();
if (moveTouchId !== null) return;
const touch = e.changedTouches[0];
moveTouchId = touch.identifier;
this.moveStartX = touch.clientX;
btnMove.classList.add('active');
}, {passive: false});
btnMove.addEventListener('touchmove', (e) => {
e.preventDefault();
if (moveTouchId === null) return;
let currentTouch = null;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === moveTouchId) {
currentTouch = e.changedTouches[i];
break;
}
}
if (!currentTouch) return;
const currentX = currentTouch.clientX;
const diff = currentX - this.moveStartX;
let force = diff / sensitivity;
if (force > 1) force = 1;
if (force < -1) force = -1;
if (Math.abs(force) < 0.15) force = 0;
this.input.moveForce = force;
}, {passive: false});
const endMove = (e) => {
e.preventDefault();
if (moveTouchId === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === moveTouchId) {
btnMove.classList.remove('active');
moveTouchId = null;
this.moveStartX = null;
this.input.moveForce = 0;
break;
}
}
};
btnMove.addEventListener('touchend', endMove);
btnMove.addEventListener('touchcancel', endMove);
// Fire Button
btnFire.addEventListener('touchstart', (e) => {
e.preventDefault();
this.input.fire = true;
btnFire.classList.add('active');
});
const endFire = (e) => {
e.preventDefault();
this.input.fire = false;
btnFire.classList.remove('active');
};
btnFire.addEventListener('touchend', endFire);
btnFire.addEventListener('pointerup', endFire);
ui.restartBtn.addEventListener('click', () => {
// ★Web Audio APIのコンテキストをユーザー操作で再開/初期化する重要処理
if (audio.useWebAudio && audio.ctx) {
if (audio.ctx.state === 'suspended') {
audio.ctx.resume();
}
}
this.start();
});
}
getTouchPos(e) {
const rect = this.canvas.getBoundingClientRect();
const clientX = e.touches[0].clientX;
const scaleX = this.canvas.width / rect.width;
return (clientX - rect.left) * scaleX;
}
start() {
audio.init();
audio.playBGM();
this.state = 'PLAYING';
this.score = 0;
this.level = 1;
this.lives = 3;
// プール内の全オブジェクトをリセット(非アクティブ化)
this.bullets.forEach(b => b.active = false);
this.enemies.forEach(e => e.active = false);
this.explosions.forEach(e => e.active = false);
this.player = new Player(this.canvas);
// ★UI強制更新(キャッシュリセット)
this.lastScore = -1;
this.lastLevel = -1;
this.lastLives = -1;
this.updateUI();
ui.overlay.style.display = 'none';
ranking.hideForm();
if (this.loopId) cancelAnimationFrame(this.loopId);
this.loop();
}
update() {
if (this.state !== 'PLAYING') return;
const newLevel = Math.floor(this.score / 1000) + 1;
if (newLevel > this.level) {
this.level = newLevel;
}
if (this.touchX !== null) {
this.player.moveTo(this.touchX);
} else {
this.player.move(this.input);
}
const now = Date.now();
if (this.input.fire && now - this.lastShotTime > 150) {
// プールから非アクティブな弾を探して発射
const bullet = this.bullets.find(b => !b.active);
if (bullet) {
bullet.activate(this.player.x + this.player.width/2 - 3, this.player.y);
this.lastShotTime = now;
audio.play('shot');
}
}
// プール内のアクティブなオブジェクトのみ更新
this.bullets.forEach(b => b.update());
this.enemies.forEach(e => e.update(this.canvas.height, this.canvas.width));
this.explosions.forEach(e => e.update());
const spawnRate = 0.02 + (this.level * 0.005);
if (Math.random() < spawnRate) {
// プールから非アクティブな敵を探して出現
const enemy = this.enemies.find(e => !e.active);
if (enemy) {
enemy.spawn(this.canvas.width, this.level, this.images.enemy);
}
}
this.checkCollisions();
this.updateUI();
}
checkCollisions() {
// アクティブなオブジェクト同士でのみ判定
this.bullets.forEach(b => {
if (!b.active) return;
this.enemies.forEach(e => {
if (!e.active) return;
if (b.x < e.x + e.width &&
b.x + b.width > e.x &&
b.y < e.y + e.height &&
b.y + b.height > e.y) {
b.active = false;
e.active = false;
// プールから爆発を取得
const explosion = this.explosions.find(ex => !ex.active);
if (explosion) {
const img = Math.random() < 0.5 ? this.images.explosion1 : this.images.explosion2;
explosion.spawn(e.x + e.width/2, e.y + e.height/2, e.width, img);
}
this.score += 100;
audio.play('hit');
}
});
});
if (this.player.invincible === 0) {
this.enemies.forEach(e => {
if (!e.active) return;
if (this.player.x < e.x + e.width &&
this.player.x + this.player.width > e.x &&
this.player.y < e.y + e.height &&
this.player.y + this.player.height > e.y) {
e.active = false;
const explosion = this.explosions.find(ex => !ex.active);
if (explosion) {
const img = Math.random() < 0.5 ? this.images.explosion1 : this.images.explosion2;
explosion.spawn(this.player.x + this.player.width/2, this.player.y + this.player.height/2, this.player.width, img);
}
this.handleDamage();
}
});
}
}
handleDamage() {
this.lives--;
this.player.invincible = 60;
audio.play('explosion');
this.canvas.style.transform = "translate(5px, 5px)";
setTimeout(() => this.canvas.style.transform = "none", 50);
if (this.lives <= 0) {
this.gameOver();
}
}
gameOver() {
this.state = 'GAMEOVER';
audio.stopBGM();
audio.play('gameover');
ui.title.innerHTML = "GAME OVER";
ui.status.innerHTML = `Final Score: <span style="color:lime;font-size:1.5em">${this.score}</span>`;
ui.restartBtn.innerText = "TRY AGAIN";
ui.overlay.style.display = 'flex';
ranking.showForm();
}
updateUI() {
// 値に変更があった時だけDOMを書き換える (高負荷対策)
if (this.score !== this.lastScore) {
ui.score.innerText = this.score;
this.lastScore = this.score;
}
if (this.level !== this.lastLevel) {
ui.level.innerText = this.level;
this.lastLevel = this.level;
}
if (this.lives !== this.lastLives) {
ui.lives.innerText = "♥".repeat(Math.max(0, this.lives));
this.lastLives = this.lives;
}
}
draw() {
this.ctx.fillStyle = "black";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const speedMult = (this.state === 'PLAYING') ? 1 + (this.level * 0.2) : 0.2;
this.stars.forEach(s => {
s.update(this.canvas.height, speedMult);
s.draw(this.ctx);
});
if (this.state === 'PLAYING' || this.state === 'GAMEOVER') {
if (this.player) this.player.draw(this.ctx);
// プール内のアクティブなオブジェクトのみ描画
this.bullets.forEach(b => b.draw(this.ctx));
this.enemies.forEach(e => e.draw(this.ctx));
this.explosions.forEach(e => e.draw(this.ctx));
}
}
loop() {
this.update();
this.draw();
if (this.state === 'PLAYING') {
this.loopId = requestAnimationFrame(() => this.loop());
} else {
this.draw();
requestAnimationFrame(() => this.draw());
}
}
}
const game = new Game();
function resizeGame() {
}
window.addEventListener('resize', resizeGame);
resizeGame();
</script>
</body>
</html>
まとめ:諦めなければゲームは進化する
Ver 1.0から始まったこのシューティングゲームプロジェクトも、Ver 2.93でついに一つの完成形を見ました。
- モダンなデザインへの刷新
- スマホ・タブレットへの完全対応
- アナログ操作による快適なプレイ感
- オブジェクトプールとWeb Audio APIによる究極のパフォーマンス改善
これだけの要素を詰め込んでも、コードは1つのHTMLファイルに収まっています。これがWeb開発の面白さであり、奥深さでもあります。
もし「自分も作ってみたい!」と思った方は、ぜひこのコードをコピーして遊んでみてください。そして、自由に改造してみてください。「弾の色を変える」「敵の動きを変える」そんな小さな改造が、あなただけのVer 3.0への第一歩になるはずです。
最後まで読んでいただき、本当にありがとうございました!

コメント