このサイトはアフィリエイトリンクを含んでいます
スポンサーリンク

JSシューティングゲームVer3.14完成!トリプルタップで炸裂するミサイルとASUS PC問題の解決

シューティングゲームVer3.14のアイキャッチ画像。宇宙空間で自機が強力なミサイルを発射し、オレンジ色の爆発でネオンカラーの敵を一掃している様子 作ってみた!
スポンサーリンク
スポンサーリンク

ついにこの時がやってきました。長らく開発を続けてきたJavaScript製シューティングゲームですが、Ver2.93の公開から数多のアップデートを重ね、ついに「Ver3.14」へと到達しました。今回のアップデートは単なるバグ修正ではありません。「敵キャラの多様化」「ボス戦の本格化」「パワーアップシステムの刷新」、そして何と言っても目玉となるのが、スマホ操作でも爽快感を味わえる「トリプルタップミサイル」の実装です。

あんちゃん
あんちゃん

PCなら矢印キーで移動、Zキーかスペースでショット発射!
そしてここが重要!発射キーを「タンタンタンッ!」と3連打すると、必殺のBOMB(ミサイル)が炸裂するぞ!!!
​スマホのみんなは画面下のコントローラーを使ってね。矢印をスワイプして移動、Shotボタンで攻撃だ!(もちろん3連打も使えるよ!)
​さあ、画面の「START MISSION」を押して出撃だ!

この記事では、Ver2.93からどのような経緯でVer3系へと進化したのか、開発中に直面した「高性能PCだとゲームが爆速になる問題」をどう解決したのか、そして最新のVer3.14の全ソースコードとその仕組みを徹底的に解説していきます。プログラミング初心者の方も、ゲーム開発に興味がある方も、ぜひ最後までお付き合いください。

スポンサーリンク
スポンサーリンク

Ver2.93からの飛躍的な進化とVer3への道のり

ゲームグラフィックの進化を比較したイラスト。左側は初期のシンプルな白黒の敵機、右側はVer3系のカラフルなネオンカラーの敵機編隊と背後にそびえる巨大なボス戦艦

Ver2.93までは、正直に言えば「動くけれど、ゲームとしての深みはまだまだ」という状態でした。ランキング機能がついたり、スマホ対応したりとシステム面は整っていましたが、肝心のゲーム性が「レベルが上がると敵が速くなるだけ」というシンプルなものだったのです。そこからVer3に向けて、ゲームとしての面白さを追求する長い旅が始まりました。まずは、Ver3.0から3.12までの激動の改善プロセスを振り返ってみましょう。

敵キャラクターの多様化とボス戦の実装(Ver3.01〜3.11)

これまでのバージョンでは、敵キャラといえば1種類の画像がただ落ちてくるだけでした。しかし、シューティングゲームの醍醐味といえば、様々な動きをする敵キャラと、画面を圧迫する巨大なボスですよね。そこでVer3.0系では、まず敵キャラクターの画像を5種類(teki01〜teki05)に増やし、それぞれに固有の動きと耐久力を設定しました。

さらに、Ver3.11では敵のAIを強化。ただ直進するだけでなく、左右に蛇行したり、プレイヤーを狙って弾を撃ってきたりと、一気に「ゲームらしく」なりました。そして極めつけはボスの登場です。「WARNING」の表示とともに巨大戦艦が現れ、弾幕を張ってくる。この緊張感を出すために、ボス出現時は雑魚敵をフェードアウトさせたり、HPバーを表示させたりと、演出面でもかなりのこだわりを詰め込んでいます。これによって、単調な「避けゲー」から、戦略が必要な「シューティング」へと変貌を遂げました。

ASUSのPCだと倍速で動く!?フレームレート問題の解決(Ver3.12)

開発中に最も頭を悩ませたのが、実行環境による動作スピードの違いです。ぼくが普段使っているASUSの高性能PCで電源ケーブルを繋いでプレイすると、GPUが本気を出してしまい、ブラウザの描画更新頻度(FPS)が上がってゲームスピードが倍速になってしまう現象が発生しました。これでは「高スペックPCを持っている人は難易度ナイトメア」という理不尽なゲームになってしまいます。

そこでVer3.12で導入したのが、「Delta Time(デルタタイム)」の概念です。これは、「前のフレームから今のフレームまで何ミリ秒経過したか」を計算し、その経過時間(dt)を移動量に掛け合わせる手法です。これにより、FPSが60だろうが120だろうが、あるいは処理落ちして30になろうが、キャラクターが1秒間に進む距離は一定になります。この実装によって、どんな端末でも公平に遊べる環境がついに整いました。

被弾=即終了からの脱却!シールドシステムの採用(Ver3.13)

昔ながらのシューティングゲームにある「一発当たったらパワーアップが全部なくなる」という仕様。あれ、今の時代にはちょっとストイックすぎますよね。苦労して最強状態まで育てたのに、たった一発のミスで初期装備に戻される絶望感と言ったらありません。そこでVer3.13では、パワーアップシステムを「実質的なバリア(シールド)」として機能するように変更しました。

具体的には、パワーアップ状態で敵の弾に当たっても、即座に残機が減るのではなく、「パワーレベルが1下がるだけ」で済みます。つまり、パワーアップアイテムを取れば取るほど、攻撃力が上がると同時に防御力も上がるわけです。この変更によって、初心者でも粘り強く戦えるようになり、上級者は高火力を維持する緊張感を楽しめる、絶妙なゲームバランスが生まれました。

隠しコマンド的必殺技「トリプルタップミサイル」の実装

スマートフォンでのゲームプレイイメージ。親指が素早くトリプルタップする残像と、画面内で必殺技のミサイルが炸裂し、オレンジ色の衝撃波で敵を一掃している瞬間

そして今回の最新版、Ver3.14で追加されたのが「トリプルタップミサイル」です。スマホでのプレイを想定したとき、画面上のボタンを増やすと操作が煩雑になります。そこで、既存の「Shotボタン」を素早く3回タップすることで発動する特殊攻撃を実装しました。これが開発者としても会心の出来栄えなんです。

なぜ「ボタン」ではなく「トリプルタップ」なのか

UIデザインの観点から言えば、画面上に「ボム発射ボタン」を置くのが一番分かりやすいはずです。しかし、スマホの狭い画面でバーチャルパッドを操作しながら、さらに別のボタンを押すというのは意外と難しいもの。指が迷子になったり、押し間違えたりするストレスがあります。

そこで採用したのが、攻撃ボタン(Shotボタン)の連打検知です。これなら、普段攻撃している指のリズムを変えるだけで必殺技が出せます。ピンチの時に「うおおお!」と連打する行為がそのまま起死回生のボムに繋がる。この直感的な操作感が、ゲームへの没入感を高めてくれると考えました。PCのキーボード操作でも「Zキー」を3回連打すれば発動するようにしてあるので、連射する爽快感はデバイスを問いません。

画面全体を焼き尽くす「Screen Nuke」の快感

このミサイル、ただの強力な弾ではありません。発射されると画面中央へ飛んでいき、そこで大爆発を起こします。この爆発は単なるエフェクトではなく、プログラム的には以下の処理を一瞬で行っています。

  1. 全画面の敵に大ダメージを与える(雑魚敵ならほぼ即死、ボスにも大打撃)。
  2. 画面上の敵弾をすべて消去する
  3. 派手な爆発エフェクトとサウンドを再生する

いわゆる「ボム」としての役割です。弾幕に追い詰められて「もうダメだ!」という瞬間に、タタタンッ!とタップして画面が一掃される。このカタルシスこそがシューティングゲームの醍醐味。1面につき3発までという制限を設けることで、使い所を見極める戦略性も生まれています。

プログラムで見る「連打検知」のロジック

では、技術的にどうやって「3回連打」を判定しているのか、コードの核心部分を見てみましょう。JavaScriptの Date.now() を使って、タップ間の時間を計測しています。

handleTap() {
    if (this.state !== 'PLAYING') return;

    const now = Date.now();
    // 300ミリ秒以内に次のタップがあれば連打とみなす
    if (now - this.lastTapTime < 300) { 
        this.tapCount++;
    } else {
        // 時間が空いたらカウントリセット
        this.tapCount = 1;
    }
    this.lastTapTime = now;

    // 3回カウントされたら発射!
    if (this.tapCount >= 3) {
        this.fireMissile();
        this.tapCount = 0; // カウントリセット
    }
}

非常にシンプルですが、これだけで驚くほど快適に動作します。300ms という猶予時間は、早すぎず遅すぎず、意図的な連打だけを拾う絶妙な調整値です。こういった「触り心地」に関わる数値を調整するのも、ゲーム開発の楽しいところですね。

Ver3.14 全ソースコード公開

ゲーム開発のイメージイラスト。エディタに表示されたJavaScriptコードから、ホログラム状の自機や敵キャラクターが飛び出し、デジタル空間と融合している様子

それでは、これら全ての機能を実装したVer3.14の全コードを公開します。
このコードをHTMLファイルとして保存すれば、ブラウザだけですぐに遊ぶことができます。ランキング機能を使うにはGoogle Apps Script(GAS)の設定が必要ですが、ゲーム本編だけならこのHTML単体で動作します。

注意: ランキング機能を使用する場合は、コード内の const RANKING_API_URL = ""; の部分に、ご自身のGASのウェブアプリURLを入力してください。

<!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 3.14 - Triple Tap Missile</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;
            --neon-orange: #ff9d00;
            --boss-hp-color: #ff0000;
        }

        * {
            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;
        }

        /* ボスHPバー (通常は非表示) */
        #boss-hp-container {
            position: absolute;
            top: 10px;
            left: 50%;
            transform: translateX(-50%);
            width: 80%;
            height: 15px;
            background: rgba(0,0,0,0.5);
            border: 1px solid #fff;
            border-radius: 8px;
            display: none;
            overflow: hidden;
            z-index: 5;
        }
        #boss-hp-bar {
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, #ff0000, #ff5500);
            transition: width 0.2s;
        }
        #boss-name {
            position: absolute;
            top: 28px;
            left: 50%;
            transform: translateX(-50%);
            color: #ff5555;
            font-size: 12px;
            font-weight: bold;
            text-shadow: 1px 1px 0 #000;
            display: none;
            z-index: 5;
        }

        /* 警告表示 */
        #warning-msg {
            position: absolute;
            top: 40%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: #ff0000;
            font-size: 3rem;
            font-weight: bold;
            text-shadow: 0 0 10px #ff0000;
            display: none;
            z-index: 6;
            animation: blink 0.5s infinite alternate;
            pointer-events: none;
        }
        @keyframes blink { from { opacity: 1; } to { opacity: 0.3; } }

        /* 右パネル:ステータス */
        .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); }
        #power { color: var(--neon-orange); } 
        #bombs { color: #ff3333; } /* ボム残弾用 */

        /* オーバーレイ */
        #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">
        <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>

        <div class="game-container">
            <canvas id="gameCanvas" width="480" height="600"></canvas>

            <div class="move-area" id="touch-area"></div>

            <div id="boss-hp-container">
                <div id="boss-hp-bar"></div>
            </div>
            <div id="boss-name">WARNING: HUGE BATTLESHIP APPROACHING</div>

            <div id="warning-msg">WARNING!</div>

            <div id="overlay">
                <h1 id="title-text">SHOOTING<br><span style="font-size:0.6em;color:#ff9d00;">Ver 3.14</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>

        <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 class="stat-item" style="border-left-color: var(--neon-orange);">
                    <div class="label">POWER</div>
                    <div id="power" class="stat-value">0</div>
                </div>
                <div class="stat-item" style="border-left-color: #ff3333;">
                    <div class="label">BOMB</div>
                    <div id="bombs" class="stat-value">3</div>
                </div>
            </div>
            <button id="mobile-ranking-btn">🏆 RANKING</button>
        </div>

        <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>

    <div class="ranking-modal" id="ranking-modal">
        <div class="ranking-modal-content">
            <div class="close-modal" id="close-modal">&times;</div>
            <div id="modal-ranking-container" style="width:100%; height:100%;"></div>
        </div>
    </div>

    <script>
        // ==========================================
        // CONFIGURATION
        // ==========================================

        const RANKING_API_URL = ""; 

        // ★アセット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',
            teki01: 'https://tokodomo.xyz/wp-content/uploads/2025/12/teki01.png',
            teki02: 'https://tokodomo.xyz/wp-content/uploads/2025/12/teki02.png',
            teki03: 'https://tokodomo.xyz/wp-content/uploads/2025/12/teki03.png',
            teki04: 'https://tokodomo.xyz/wp-content/uploads/2025/12/teki04.png',
            teki05: 'https://tokodomo.xyz/wp-content/uploads/2025/12/teki05.png',
            boss: 'https://tokodomo.xyz/wp-content/uploads/2025/12/tekiBoss01.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",
            powerup: "https://tokodomo.xyz/wp-content/uploads/2024/09/hold.mp3",
            boss_alert: "https://tokodomo.xyz/wp-content/uploads/2024/09/ready.mp3",
            missile: "https://tokodomo.xyz/wp-content/uploads/2024/09/rotate.mp3" // ミサイル発射音(仮)
        };

        // ==========================================
        // UTILITIES & MANAGERS
        // ==========================================

        class AudioController {
            constructor() { 
                this.ctx = null;
                this.buffers = {};
                this.isMuted = false;
                this.loaded = false;
                this.fallbackPool = {};
                this.useWebAudio = true;
                this.lastPlayed = {};
            }

            init() {
                if (this.loaded) return;
                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) {
                    this.useWebAudio = false;
                    this.initFallback();
                }
                this.loaded = true;
            }

            async loadAllWebAudio() {
                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) {}
                });
                if (SOUND_URLS.bgm) {
                    this.bgm = new Audio(SOUND_URLS.bgm);
                    this.bgm.loop = true;
                    this.bgm.volume = 0.3;
                }
            }

            initFallback() {
                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;
                if (this.useWebAudio && this.ctx && this.buffers[name]) {
                    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;
                }
                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(()=>{});
                    }
                }
            }

            playBGM() { 
                if (this.isMuted || !this.bgm) return;
                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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m])); }
        }
        const ranking = new RankingManager();

        const ui = {
            score: document.getElementById('score'),
            level: document.getElementById('level'),
            lives: document.getElementById('lives'),
            power: document.getElementById('power'),
            bombs: document.getElementById('bombs'),
            overlay: document.getElementById('overlay'),
            title: document.getElementById('title-text'),
            status: document.getElementById('status-message'),
            restartBtn: document.getElementById('restart-btn'),
            // Boss UI
            bossHpContainer: document.getElementById('boss-hp-container'),
            bossHpBar: document.getElementById('boss-hp-bar'),
            bossName: document.getElementById('boss-name'),
            warningMsg: document.getElementById('warning-msg')
        };

        // ==========================================
        // GAME ENTITIES
        // ==========================================

        // ミサイルクラス(新規)
        class Missile {
            constructor() {
                this.active = false;
                this.x = 0;
                this.y = 0;
                this.targetY = 300; // 画面中央付近
                this.speed = 8;
                this.width = 10;
                this.height = 30;
            }

            spawn(x, y) {
                this.x = x;
                this.y = y;
                this.active = true;
                this.targetY = 250 + Math.random() * 100; // 中央付近でランダム
            }

            update(dt) {
                if (!this.active) return;
                this.y -= this.speed * dt;

                // 目標高度に到達したら炸裂
                if (this.y <= this.targetY) {
                    this.explode();
                }
            }

            explode() {
                this.active = false;
                // 画面全体攻撃処理
                game.triggerBombEffect(this.x, this.y);
            }

            draw(ctx) {
                if (!this.active) return;
                ctx.save();
                ctx.translate(this.x, this.y);
                ctx.fillStyle = "#ffaa00";
                ctx.fillRect(-5, -15, 10, 30);

                // 噴射炎
                ctx.fillStyle = `rgba(255, 100, 0, ${0.5 + Math.random()*0.5})`;
                ctx.beginPath();
                ctx.moveTo(-3, 15);
                ctx.lineTo(3, 15);
                ctx.lineTo(0, 30 + Math.random()*10);
                ctx.fill();
                ctx.restore();
            }
        }

        class PowerItem {
            constructor() {
                this.width = 24;
                this.height = 24;
                this.x = 0;
                this.y = 0;
                this.active = false;
                this.speedY = 2;
                this.wobble = 0;
            }
            spawn(x, y) {
                this.x = x;
                this.y = y;
                this.active = true;
                this.wobble = Math.random() * Math.PI * 2;
            }
            // dt追加
            update(canvasHeight, dt) {
                if (!this.active) return;
                this.y += this.speedY * dt;
                this.wobble += 0.1 * dt;
                this.x += Math.sin(this.wobble) * 1.5 * dt;
                if (this.y > canvasHeight) this.active = false;
            }
            draw(ctx) {
                if (!this.active) return;
                ctx.save();
                ctx.translate(this.x + this.width/2, this.y + this.height/2);
                const scale = 1.0 + Math.sin(Date.now() / 100) * 0.1;
                ctx.scale(scale, scale);
                ctx.fillStyle = "#ff3333";
                ctx.beginPath();
                ctx.roundRect(-12, -12, 24, 24, 5);
                ctx.fill();
                ctx.strokeStyle = "#fff";
                ctx.lineWidth = 2;
                ctx.stroke();
                ctx.fillStyle = "#fff";
                ctx.font = "bold 16px sans-serif";
                ctx.textAlign = "center";
                ctx.textBaseline = "middle";
                ctx.fillText("P", 0, 1);
                ctx.restore();
            }
        }

        class Bullet {
            constructor() {
                this.width = 6;
                this.height = 16;
                this.speed = 12;
                this.active = false;
                this.x = 0;
                this.y = 0;
                this.vx = 0;
                this.vy = 0;
                this.isEnemy = false; 
            }
            activate(x, y, vx = 0, vy = -12, isEnemy = false) {
                this.x = x;
                this.y = y;
                this.vx = vx;
                this.vy = vy;
                this.isEnemy = isEnemy;
                this.active = true;
            }
            // dt追加
            update(dt) {
                if (!this.active) return;
                this.x += this.vx * dt;
                this.y += this.vy * dt;
                if (this.y < -this.height || this.y > 600 || this.x < -10 || this.x > 490) {
                    this.active = false;
                }
            }
            draw(ctx) {
                if (!this.active) return;
                ctx.save();
                ctx.translate(this.x + this.width/2, this.y + this.height/2);
                ctx.rotate(this.vx * 0.05);

                if (this.isEnemy) {
                    ctx.fillStyle = "#ff3333"; 
                    ctx.beginPath();
                    ctx.arc(0, 0, 5, 0, Math.PI * 2);
                    ctx.fill();
                } else {
                    ctx.fillStyle = "#00f3ff"; 
                    ctx.fillRect(-this.width/2, -this.height/2, this.width, this.height);
                }
                ctx.restore();
            }
        }

        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;
                this.hp = 1;
                this.type = 0;
                this.time = 0;
                this.hitTimer = 0;
                this.fireTimer = 0; 
            }

            spawn(canvasWidth, level, type) {
                this.active = true;
                this.type = type;
                this.time = 0;
                this.hitTimer = 0;
                this.fireTimer = Math.floor(Math.random() * 60); 

                switch(type) {
                    case 0: this.width = 30; this.height = 30; break;
                    case 1: this.width = 40; this.height = 40; break;
                    case 2: this.width = 35; this.height = 35; break;
                    case 3: this.width = 50; this.height = 50; break;
                    case 4: this.width = 30; this.height = 30; break;
                    case 5: this.width = 45; this.height = 45; break;
                    default: this.width = 40; this.height = 40;
                }

                let imgKey = (type === 0) ? 'enemy' : `teki0${type}`;
                this.img = game.images[imgKey] || game.images.enemy;

                this.x = Math.random() * (canvasWidth - this.width);
                this.y = -this.height;

                switch(type) {
                    case 0: // 最弱
                        this.hp = 1; this.speedY = 2 + (level * 0.1); this.speedX = 0; break;
                    case 1: // teki01
                        this.hp = 2 + Math.floor(level/5); this.speedY = 2 + (level * 0.2); this.speedX = 0; break;
                    case 2: // teki02
                        this.hp = 2 + Math.floor(level/4); this.speedY = 1.5 + (level * 0.2); this.speedX = 0; break;
                    case 3: // teki03
                        this.hp = 5 + Math.floor(level/2); this.speedY = 1 + (level * 0.1); this.speedX = 0; break;
                    case 4: // teki04
                        this.hp = 1; this.speedY = 5 + (level * 0.3); this.speedX = 0; break;
                    case 5: // teki05
                        this.hp = 4 + Math.floor(level/3); this.speedY = 2 + (level * 0.2); this.speedX = (Math.random() < 0.5 ? -1 : 1) * 2; break;
                    default:
                        this.hp = 1; this.speedY = 2; this.speedX = 0;
                }
            }

            takeDamage(amount = 1) {
                this.hp -= amount;
                this.hitTimer = 3; 
            }

            // dt追加
            update(canvasHeight, canvasWidth, player, dt) {
                if (!this.active) return;
                this.time += dt;
                if (this.hitTimer > 0) this.hitTimer -= dt;

                // 動きの制御
                if (this.type === 2) {
                    this.x += Math.sin(this.time * 0.05) * 2 * dt;
                } else if (this.type === 5) {
                    this.x += this.speedX * dt;
                    if (this.x < 0 || this.x > canvasWidth - this.width) this.speedX *= -1;
                } else {
                    this.x += this.speedX * dt;
                }

                this.y += this.speedY * dt;
                if (this.y > canvasHeight) this.active = false;

                // 攻撃ロジック (タイマーもdtで進める)
                this.fireTimer += dt;
                const cx = this.x + this.width / 2;
                const cy = this.y + this.height;

                if (this.type === 0) {
                } 
                else if (this.type === 1) {
                    if (this.fireTimer > 120) {
                        game.fireBullet(cx, cy, 0, 4, true);
                        this.fireTimer = 0;
                    }
                }
                else if (this.type === 2) {
                    if (this.fireTimer > 100) {
                        const angle = Math.atan2(player.y - cy, player.x - cx);
                        const speed = 4;
                        game.fireBullet(cx, cy, Math.cos(angle)*speed, Math.sin(angle)*speed, true);
                        this.fireTimer = 0;
                    }
                }
                else if (this.type === 3) {
                    if (this.fireTimer > 150) {
                        game.fireBullet(cx, cy, 0, 3, true);
                        game.fireBullet(cx, cy, -2, 2.5, true);
                        game.fireBullet(cx, cy, 2, 2.5, true);
                        this.fireTimer = 0;
                    }
                }
                else if (this.type === 4) {
                    if (this.fireTimer > 60) {
                        game.fireBullet(cx, cy, 0, 7, true);
                        this.fireTimer = 0;
                    }
                }
                else if (this.type === 5) {
                    if (this.fireTimer > 120) {
                        game.fireBullet(cx, cy, 0, 4, true);
                        game.fireBullet(cx, cy, -1.5, 3.8, true);
                        game.fireBullet(cx, cy, 1.5, 3.8, true);
                        game.fireBullet(cx, cy, -3, 3.5, true);
                        game.fireBullet(cx, cy, 3, 3.5, true);
                        this.fireTimer = 0;
                    }
                }
            }

            draw(ctx) {
                if (!this.active) return;

                ctx.save();
                if (this.hitTimer > 0) {
                    ctx.translate(Math.random()*4-2, Math.random()*4-2); 
                    ctx.globalCompositeOperation = "lighter";
                    ctx.filter = "brightness(200%)"; 
                }

                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);
                }
                ctx.restore();
            }
        }

        class Boss {
            constructor() {
                this.active = false;
                this.width = 150;
                this.height = 150;
                this.x = 0;
                this.y = -200;
                this.hp = 100;
                this.maxHp = 100;
                this.img = null;
                this.state = 'enter'; 
                this.vx = 2;
                this.moveTimer = 0;
                this.attackTimer = 0;
                this.hitTimer = 0;
                this.pattern = 0; 
            }

            spawn(level) {
                this.active = true;
                this.state = 'enter';
                this.y = -this.height;
                this.x = (480 - this.width) / 2;
                this.maxHp = 40 + (level * 10); 
                this.hp = this.maxHp;
                this.img = game.images.boss;
                this.attackTimer = 0;
                this.hitTimer = 0;
                this.pattern = 0;

                ui.bossHpContainer.style.display = 'block';
                ui.bossName.style.display = 'block';
                ui.bossHpBar.style.width = '100%';

                audio.play('boss_alert');
            }

            takeDamage(amount = 1) {
                this.hp -= amount;
                this.hitTimer = 3;
            }

            // dt追加
            update(canvasWidth, player, dt) {
                if (!this.active) return;
                if (this.hitTimer > 0) this.hitTimer -= dt;

                if (this.state === 'enter') {
                    this.y += 2 * dt;
                    if (this.y >= 50) {
                        this.y = 50;
                        this.state = 'fight';
                    }
                } else if (this.state === 'fight') {
                    this.moveTimer += dt;
                    this.x += this.vx * dt;
                    if (this.x < 0 || this.x > canvasWidth - this.width) {
                        this.vx *= -1;
                    }

                    this.attackTimer += dt;
                    const interval = (this.hp < this.maxHp / 2) ? 90 : 150; 

                    if (this.attackTimer > interval) { 
                        this.attackTimer = 0;
                        this.fireAttack(player);
                    }

                    const percent = Math.max(0, (this.hp / this.maxHp) * 100);
                    ui.bossHpBar.style.width = `${percent}%`;

                    if (this.hp <= 0) {
                        this.active = false;
                        game.onBossDefeated();
                        ui.bossHpContainer.style.display = 'none';
                        ui.bossName.style.display = 'none';
                    }
                }
            }

            fireAttack(player) {
                const cx = this.x + this.width / 2;
                const cy = this.y + this.height;

                const p = Math.floor(Math.random() * 4);

                if (p === 0) {
                    game.fireBullet(cx, cy, 0, 4, true);
                    game.fireBullet(cx, cy, -2, 3, true);
                    game.fireBullet(cx, cy, 2, 3, true);
                } else if (p === 1) {
                    let count = 0;
                    const interval = setInterval(() => {
                        if (!this.active) { clearInterval(interval); return; }
                        const angle = Math.atan2(player.y - cy, player.x - cx);
                        const speed = 5;
                        game.fireBullet(cx, cy, Math.cos(angle)*speed, Math.sin(angle)*speed, true);
                        count++;
                        if (count >= 3) clearInterval(interval);
                    }, 100);
                } else if (p === 2) {
                    for (let i = 0; i < 8; i++) {
                        const angle = (Math.PI * 2 / 8) * i;
                        game.fireBullet(cx, cy, Math.cos(angle)*3, Math.sin(angle)*3, true);
                    }
                } else if (p === 3) {
                    game.fireBullet(cx, cy, Math.random()*4-2, 6, true);
                    game.fireBullet(cx, cy, Math.random()*4-2, 6, true);
                    game.fireBullet(cx, cy, Math.random()*4-2, 6, true);
                }
            }

            draw(ctx) {
                if (!this.active) return;
                ctx.save();
                if (this.hitTimer > 0) {
                    ctx.translate(Math.random()*4-2, Math.random()*4-2);
                    ctx.globalCompositeOperation = "lighter";
                    ctx.filter = "brightness(150%)"; 
                }
                if (this.img) {
                    ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
                } else {
                    ctx.fillStyle = "purple";
                    ctx.fillRect(this.x, this.y, this.width, this.height);
                }
                ctx.restore();
            }
        }

        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;
            }
            // dt追加
            update(dt) {
                if (!this.active) return;
                this.frame += dt; // アニメーションもdt依存
                this.y += 1 * dt;
                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;
            }
            // dt追加
            update(h, speedMult, dt) {
                this.y += this.speed * speedMult * dt;
                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;
                this.powerLevel = 0; 
                this.maxPower = 3;
            }
            // dt追加
            move(input, dt) {
                let force = 0;
                if (input.left) {
                    force = -1;
                } else if (input.right) {
                    force = 1;
                } else if (input.moveForce !== 0) {
                    force = input.moveForce;
                }
                this.x += this.speed * force * dt; // 移動量にdtを掛ける
                if (this.x < 0) this.x = 0;
                if (this.x + this.width > this.canvas.width) this.x = this.canvas.width - this.width;
            }
            // dt追加 (タッチ追従)
            moveTo(targetX, dt) {
                const center = this.x + this.width / 2;
                const diff = targetX - center;
                // 補間係数もdtで調整 (簡易的に 0.2 * dt)
                this.x += diff * 0.2 * dt; 
                if (this.x < 0) this.x = 0;
                if (this.x + this.width > this.canvas.width) this.x = this.canvas.width - this.width;
            }
            powerUp() {
                if (this.powerLevel < this.maxPower) {
                    this.powerLevel++;
                    audio.play('powerup');
                    game.addFloatingText("POWER UP!", this.x, this.y);
                } else {
                    game.score += 500; 
                    game.addFloatingText("1000pts", this.x, this.y);
                }
            }
            // ★変更: ダメージを受けた時の処理
            takeHit() {
                if (this.powerLevel > 0) {
                    this.powerLevel--;
                    return true; // バリアで防いだ
                }
                return false; // 防げなかった
            }
            resetPower() {
                this.powerLevel = 0;
            }
            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();
                }
                if (this.powerLevel > 0) {
                    ctx.save();
                    ctx.globalCompositeOperation = "lighter";
                    ctx.strokeStyle = `rgba(255, 157, 0, ${0.2 * this.powerLevel})`;
                    ctx.lineWidth = 3;
                    ctx.beginPath();
                    ctx.arc(this.x + this.width/2, this.y + this.height/2, 35, 0, Math.PI*2);
                    ctx.stroke();
                    ctx.restore();
                }
            }
        }

        class FloatingText {
            constructor(text, x, y) {
                this.text = text;
                this.x = x;
                this.y = y;
                this.life = 60;
                this.active = true;
            }
            // dt追加
            update(dt) {
                this.y -= 1 * dt;
                this.life -= dt;
                if(this.life <= 0) this.active = false;
            }
            draw(ctx) {
                ctx.save();
                ctx.fillStyle = `rgba(255, 255, 255, ${this.life/60})`;
                ctx.font = "bold 20px monospace";
                ctx.fillText(this.text, this.x, this.y);
                ctx.restore();
            }
        }

        // ==========================================
        // 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;
                this.missilesLeft = 3; // ミサイル残弾 (1面3発)

                this.nextLevelScore = 2000; 
                this.lastScore = -1;
                this.lastLevel = -1;
                this.lastLives = -1;
                this.lastPower = -1;
                this.lastBombs = -1;

                this.player = null;
                this.boss = null;
                this.bossPhase = 'NONE'; 

                this.stars = [];
                this.bullets = [];
                this.enemies = [];
                this.explosions = [];
                this.powerItems = [];
                this.floatingTexts = [];
                this.missiles = []; // ミサイルオブジェクトプール

                for(let i=0; i<200; 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());
                for(let i=0; i<5; i++) this.powerItems.push(new PowerItem());
                for(let i=0; i<3; i++) this.missiles.push(new Missile());

                this.boss = new Boss();

                this.input = { left: false, right: false, fire: false, moveForce: 0 };
                this.touchX = null;
                this.moveStartX = null;
                this.lastShotTime = 0;

                // トリプルタップ検出用
                this.tapCount = 0;
                this.lastTapTime = 0;

                this.loopId = null;
                this.lastTime = 0; 

                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() {
                // キーボード: Zキー3回連打でも発動
                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 === ' ') {
                        if (!this.input.fire) { // 押し込み始めだけ検知
                            this.handleTap();
                        }
                        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;
                });

                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;
                });

                const btnMove = document.getElementById('btn-move');
                const btnFire = document.getElementById('btn-fire');
                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);

                // Shotボタンタップ
                btnFire.addEventListener('touchstart', (e) => { 
                    e.preventDefault(); 
                    this.input.fire = true; 
                    this.handleTap(); // タップ検知
                    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', () => {
                    if (audio.useWebAudio && audio.ctx) {
                        if (audio.ctx.state === 'suspended') {
                            audio.ctx.resume();
                        }
                    }
                    this.start();
                });
            }

            handleTap() {
                if (this.state !== 'PLAYING') return;

                const now = Date.now();
                if (now - this.lastTapTime < 300) { // 300ms以内の連打
                    this.tapCount++;
                } else {
                    this.tapCount = 1;
                }
                this.lastTapTime = now;

                if (this.tapCount >= 3) {
                    this.fireMissile();
                    this.tapCount = 0;
                }
            }

            fireMissile() {
                if (this.missilesLeft <= 0) return;

                // 発射処理
                const missile = this.missiles.find(m => !m.active);
                if (missile) {
                    this.missilesLeft--;
                    missile.spawn(this.player.x + this.player.width/2, this.player.y);
                    audio.play('missile');
                    this.addFloatingText("MISSILE!", this.player.x, this.player.y - 40);
                }
            }

            triggerBombEffect(centerX, centerY) {
                // 画面全体攻撃(大ダメージ)
                audio.play('explosion'); // 重ねて迫力を出す
                audio.play('explosion');

                // 演出: 巨大な爆発
                const explosion = this.explosions.find(ex => !ex.active);
                if (explosion) {
                    explosion.spawn(centerX, centerY, 300, this.images.explosion1);
                }

                // 敵へのダメージ判定
                this.enemies.forEach(e => {
                    if (e.active) {
                        e.takeDamage(10); // 即死級ダメージ
                        if (e.hp <= 0) {
                            e.active = false;
                            this.score += 100 * (e.type + 1);
                            // 爆発エフェクト
                            const ex = this.explosions.find(x => !x.active);
                            if (ex) ex.spawn(e.x+e.width/2, e.y+e.height/2, e.width, this.images.explosion2);
                        }
                    }
                });

                // ボスへのダメージ
                if (this.boss && this.boss.active && this.boss.state === 'fight') {
                    this.boss.takeDamage(20); // ボスには20ダメージ
                    if (this.boss.hp <= 0) {
                        this.boss.active = false;
                        this.onBossDefeated();
                        ui.bossHpContainer.style.display = 'none';
                        ui.bossName.style.display = 'none';
                    }
                }

                // 敵弾消去
                this.bullets.forEach(b => {
                    if (b.active && b.isEnemy) b.active = false;
                });
            }

            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;
            }

            addFloatingText(text, x, y) {
                this.floatingTexts.push(new FloatingText(text, x, y));
            }

            start() {
                audio.init();
                audio.playBGM();

                this.state = 'PLAYING';
                this.score = 0;
                this.level = 1;
                this.lives = 3;
                this.missilesLeft = 3; // 初期ボム3発

                this.nextLevelScore = 2000; 
                this.lastScore = -1;
                this.lastLevel = -1;
                this.lastLives = -1;
                this.lastPower = -1;
                this.lastBombs = -1;

                this.bossPhase = 'NONE';
                ui.warningMsg.style.display = 'none';

                this.bullets.forEach(b => b.active = false);
                this.enemies.forEach(e => e.active = false);
                this.explosions.forEach(e => e.active = false);
                this.powerItems.forEach(p => p.active = false);
                this.missiles.forEach(m => m.active = false);
                this.floatingTexts = [];

                if(this.boss) this.boss.active = false;
                ui.bossHpContainer.style.display = 'none';
                ui.bossName.style.display = 'none';

                this.player = new Player(this.canvas);

                this.updateUI();

                ui.overlay.style.display = 'none';
                ranking.hideForm();

                this.lastTime = performance.now(); // スタート時にリセット

                if (this.loopId) cancelAnimationFrame(this.loopId);
                this.loop();
            }

            fireBullet(x, y, vx, vy, isEnemy = false) {
                const bullet = this.bullets.find(b => !b.active);
                if (bullet) {
                    bullet.activate(x, y, vx, vy, isEnemy);
                }
            }

            onBossDefeated() {
                this.bossPhase = 'NONE';
                this.score += 5000;
                this.addFloatingText("BOSS DEFEATED!", this.canvas.width/2 - 70, this.canvas.height/2);

                this.level++;
                this.nextLevelScore = this.score + 3000 + (this.level * 1000); 

                // ボス撃破でミサイル回復
                this.missilesLeft = 3;
                this.addFloatingText("MISSILES RELOADED!", this.canvas.width/2 - 80, this.canvas.height/2 + 30);

                for(let i=0; i<10; i++) {
                    setTimeout(() => {
                        const explosion = this.explosions.find(ex => !ex.active);
                        if (explosion) {
                            explosion.spawn(
                                this.boss.x + Math.random()*this.boss.width, 
                                this.boss.y + Math.random()*this.boss.height, 
                                80, this.images.explosion1
                            );
                            audio.play('explosion');
                        }
                    }, i * 100);
                }
            }

            // Updateにdtを追加
            update(dt) {
                if (this.state !== 'PLAYING') return;

                if (this.bossPhase === 'NONE') {
                    if (this.score >= this.nextLevelScore) {
                        this.level++;
                        this.nextLevelScore += 2000 + (this.level * 1000);
                    }

                    if (this.level % 5 === 0 && this.bossPhase === 'NONE') {
                        this.bossPhase = 'WARNING';
                        ui.warningMsg.style.display = 'block';
                    }
                }

                if (this.bossPhase === 'WARNING') {
                    const activeEnemies = this.enemies.filter(e => e.active).length;
                    if (activeEnemies === 0) {
                        ui.warningMsg.style.display = 'none';
                        this.bossPhase = 'BATTLE';
                        this.boss.spawn(this.level);
                    }
                }

                if (this.touchX !== null) {
                    this.player.moveTo(this.touchX, dt); // タッチ移動にdtを渡す
                } else {
                    this.player.move(this.input, dt); // キー移動にdtを渡す
                }

                const now = Date.now();
                if (this.input.fire && now - this.lastShotTime > 150) {
                    const cx = this.player.x + this.player.width/2 - 3;
                    const cy = this.player.y;

                    if (this.player.powerLevel === 0) {
                        this.fireBullet(cx, cy, 0, -12);
                    } else if (this.player.powerLevel === 1) {
                        this.fireBullet(cx - 8, cy, 0, -12);
                        this.fireBullet(cx + 8, cy, 0, -12);
                    } else if (this.player.powerLevel === 2) {
                        this.fireBullet(cx, cy - 5, 0, -12); 
                        this.fireBullet(cx - 10, cy, -3, -10); 
                        this.fireBullet(cx + 10, cy, 3, -10); 
                    } else if (this.player.powerLevel >= 3) {
                        this.fireBullet(cx, cy - 5, 0, -12);
                        this.fireBullet(cx - 8, cy, -2, -11);
                        this.fireBullet(cx + 8, cy, 2, -11);
                        this.fireBullet(cx - 16, cy + 5, -5, -9);
                        this.fireBullet(cx + 16, cy + 5, 5, -9);
                    }
                    this.lastShotTime = now;
                    audio.play('shot');
                }

                // 各エンティティにdtを渡す
                this.bullets.forEach(b => b.update(dt));
                this.enemies.forEach(e => e.update(this.canvas.height, this.canvas.width, this.player, dt));
                this.explosions.forEach(e => e.update(dt));
                this.powerItems.forEach(p => p.update(this.canvas.height, dt));
                this.missiles.forEach(m => m.update(dt));
                this.boss.update(this.canvas.width, this.player, dt);

                this.floatingTexts = this.floatingTexts.filter(t => t.active);
                this.floatingTexts.forEach(t => t.update(dt));

                if (this.bossPhase === 'NONE') {
                    // スポーンレートもdtの影響を受けるべきだが、ここは簡易的に確率判定
                    // 本当はタイマー管理すべきだが、複雑になるので今回はそのままで十分機能する
                    // (FPSが高いと抽選回数が増えるが、dtを使わない確率だと敵が増えすぎる。
                    //  -> 正確には spawnTimer += dt で管理するのがベストだが、今回は簡易対応)
                    const spawnRate = (0.03 + (this.level * 0.005)) * dt; // dtを掛けて調整

                    if (Math.random() < spawnRate) {
                        const enemy = this.enemies.find(e => !e.active);
                        if (enemy) {
                            let maxType = Math.min(5, Math.floor((this.level - 1) / 2));
                            let type = Math.floor(Math.random() * (maxType + 1));
                            if (Math.random() < 0.3) type = maxType;
                            enemy.spawn(this.canvas.width, this.level, type);
                        }
                    }
                }

                if (Math.random() < 0.002 * dt) { // アイテム出現率もdt調整
                    const item = this.powerItems.find(p => !p.active);
                    if (item) {
                        item.spawn(Math.random() * (this.canvas.width - 30), -30);
                    }
                }

                this.checkCollisions();
                this.updateUI();
            }

            checkCollisions() {
                const bullets = this.bullets;
                const enemies = this.enemies;
                const player = this.player;
                const explosions = this.explosions;
                const powerItems = this.powerItems;

                for (let i = 0; i < bullets.length; i++) {
                    const b = bullets[i];
                    if (!b.active) continue;

                    if (b.isEnemy) {
                        if (player.invincible === 0 &&
                            b.x < player.x + player.width &&
                            b.x + b.width > player.x &&
                            b.y < player.y + player.height &&
                            b.y + b.height > player.y) {
                            b.active = false;
                            this.handleDamage();
                        }
                        continue; 
                    }

                    for (let j = 0; j < enemies.length; j++) {
                        const e = enemies[j];
                        if (!e.active) continue;

                        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.takeDamage();

                            if (e.hp <= 0) {
                                e.active = false;
                                const explosion = 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 * (e.type + 1);
                                audio.play('hit');
                            } else {
                                audio.play('hit');
                            }
                            break; 
                        }
                    }

                    if (b.active && this.boss.active && this.boss.state === 'fight') {
                        if (b.x < this.boss.x + this.boss.width &&
                            b.x + b.width > this.boss.x &&
                            b.y < this.boss.y + this.boss.height &&
                            b.y + b.height > this.boss.y) {
                                b.active = false;
                                this.boss.takeDamage();
                                audio.play('hit');
                        }
                    }
                }

                if (player.invincible === 0) {
                    for (let k = 0; k < enemies.length; k++) {
                        const e = enemies[k];
                        if (!e.active) continue;
                        if (player.x < e.x + e.width &&
                            player.x + player.width > e.x &&
                            player.y < e.y + e.height &&
                            player.y + player.height > e.y) {
                            e.active = false;
                            const explosion = explosions.find(ex => !ex.active);
                            if (explosion) {
                                const img = Math.random() < 0.5 ? this.images.explosion1 : this.images.explosion2;
                                explosion.spawn(player.x + player.width/2, player.y + player.height/2, player.width, img);
                            }
                            this.handleDamage();
                        }
                    }

                    if (this.boss.active && this.boss.state === 'fight') {
                        if (player.x < this.boss.x + this.boss.width &&
                            player.x + player.width > this.boss.x &&
                            player.y < this.boss.y + this.boss.height &&
                            player.y + player.height > this.boss.y) {
                            this.handleDamage();
                        }
                    }

                    for (let m = 0; m < powerItems.length; m++) {
                        const p = powerItems[m];
                        if (!p.active) continue;
                        if (player.x < p.x + p.width &&
                            player.x + player.width > p.x &&
                            player.y < p.y + p.height &&
                            player.y + player.height > p.y) {
                            p.active = false;
                            player.powerUp();
                        }
                    }
                }
            }

            // ★変更: ダメージ処理 (バリア優先)
            handleDamage() {
                // パワーがあればバリアとして消費
                if (this.player.takeHit()) {
                    this.addFloatingText("SHIELD BROKEN!", this.player.x, this.player.y - 20);
                    // バリア音としてヒット音などを代用(または専用音)
                    audio.play('hit'); 
                    this.player.invincible = 60; // 無敵時間は発生させる
                    return;
                }

                // パワーがない場合は残機減少
                this.lives--;
                this.addFloatingText("MISS...", this.player.x, this.player.y - 20);
                this.player.invincible = 120; // 復活時は長めに無敵
                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';
                ui.bossHpContainer.style.display = 'none';
                ui.bossName.style.display = 'none';
                ui.warningMsg.style.display = 'none';
                ranking.showForm();
            }

            updateUI() {
                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;
                }
                if (this.player && this.player.powerLevel !== this.lastPower) {
                    ui.power.innerText = "★".repeat(this.player.powerLevel);
                    this.lastPower = this.player.powerLevel;
                }
                // ボム表示
                if (this.missilesLeft !== this.lastBombs) {
                    ui.bombs.innerText = "●".repeat(this.missilesLeft);
                    this.lastBombs = this.missilesLeft;
                }
            }

            draw() {
                this.ctx.fillStyle = "black";
                this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
                // 星の速度は固定でよいが、dtの影響を受けるように修正済み
                const speedMult = (this.state === 'PLAYING') ? 1 + (this.level * 0.2) : 0.2;

                this.stars.forEach(s => s.draw(this.ctx));

                if (this.state === 'PLAYING' || this.state === 'GAMEOVER') {
                    this.powerItems.forEach(p => p.draw(this.ctx));
                    this.missiles.forEach(m => m.draw(this.ctx));
                    if (this.player) this.player.draw(this.ctx);
                    this.bullets.forEach(b => b.draw(this.ctx));
                    this.enemies.forEach(e => e.draw(this.ctx));
                    if(this.boss) this.boss.draw(this.ctx);
                    this.explosions.forEach(e => e.draw(this.ctx));
                    this.floatingTexts.forEach(t => t.draw(this.ctx));
                }
            }

            loop() {
                const now = performance.now();
                if (!this.lastTime) this.lastTime = now;

                // 60FPS基準の経過時間倍率 (16.66msなら1.0)
                const deltaTime = (now - this.lastTime) / (1000 / 60);
                this.lastTime = now;

                // 異常値防止 (最大4倍速まで)
                const safeDt = Math.min(deltaTime, 4.0);

                this.update(safeDt);

                // Starのupdateをここに移動
                if (this.stars) {
                    const speedMult = (this.state === 'PLAYING') ? 1 + (this.level * 0.2) : 0.2;
                    this.stars.forEach(s => s.update(this.canvas.height, speedMult, safeDt));
                }

                this.draw(); // drawは描画のみ担当

                if (this.state === 'PLAYING') {
                    this.loopId = requestAnimationFrame(() => this.loop());
                } else {
                    requestAnimationFrame(() => this.draw()); // GameOver時も描画更新
                }
            }
        }

        const game = new Game();
        function resizeGame() {}
        window.addEventListener('resize', resizeGame);
        resizeGame();
    </script>
</body>
</html>

これからの展望とまとめ

輝く宇宙の地平線に向かって飛び立つ自機の後ろ姿。遠くに新たな惑星や銀河が見え、次なるステージへの冒険を感じさせるイメージ

Ver2.93から始まったこの大改修プロジェクト。途中で「PCが速すぎてゲームにならない」という致命的な問題にぶつかったり、「ボスが出現する演出はどうすべきか」と悩んだりしましたが、こうしてVer3.14として形にできたことを嬉しく思います。

このゲームの変遷を振り返りたい方は、過去の記事もぜひチェックしてみてください。

今後は、さらにステージのバリエーションを増やしたり、ボスの攻撃パターンをAIで自動生成したりといった展開も考えています。また、ランキング機能の実装方法については、ぼくのブログ内検索で「シューティングゲーム」と検索すると関連情報が出てきますので、興味のある方はそちらも参考にしてみてください。

検索結果: シューティングゲーム -
検索結果ページです。

JavaScriptでのゲーム開発は、ブラウザさえあれば誰でも始められる素晴らしい趣味です。この記事が、あなたのゲーム開発のヒントになれば幸いです。それでは、Ver3.14の世界でハイスコアを目指して頑張ってくださいね!


コメント

タイトルとURLをコピーしました