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

【Gemini 3 Pro】スマホ完全対応!AIと作ったブラウザテトリスVer.3開発秘話【コピペOK】

Gemini 3 Proと共同開発したスマホ対応ブラウザテトリスの完成イメージ。スマートフォン画面から立体的にはじけるカラフルなブロックと、プログラムコードを生成するAIアバターの3Dイラスト AIで調べてみた
スポンサーリンク
スポンサーリンク

皆さん、こんにちは。あんちゃんです。これまでこのブログでは、AIの力を借りて「ブラウザだけで動くゲーム」を作るという挑戦を何度も続けてきました。特に「テトリス」に関しては、ChatGPTやClaudeなど、その時々の最新AIを駆使して挑んできたものの、PCでは動くけれどスマホでは操作しづらかったり、音が遅れて聞こえたりと、なかなか「完全版」と呼べるものには辿り着けずにいました。しかし今回、Googleの最新モデル「Gemini 3 Pro(プレビュー版)」とタッグを組み、ついにその壁を突破することができました。PCでの快適な操作性はもちろん、鬼門だったスマホでのタッチ操作、そして最大の難関「音声再生」の問題まで完全にクリアした、奇跡の「Ver.3」開発記録を、実際のコードと共に興奮冷めやらぬままにお届けします。

あんちゃん
あんちゃん

PCブラウザ版での操作方法はこれです↓
上矢印キーで、右回りでブロックが回るよ。
左右の矢印キーでブロックを左右に動かせるよ!
下矢印キーはブロックが下にすーっと落ちます。
最初に「Click to Start」ボタンを押してね。

スマホ版はゲーム下のボタンで操作してください!

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

終わらないテトリス開発の旅と、新たな相棒Gemini

過去の失敗を乗り越えGeminiと共にテトリス開発へ挑む開発者。バグで崩れたパズルブロックの道の先に希望を見出す、AI共同開発のスタートを描いたイメージイラスト

これまでの挑戦を知っている方も、初めての方も、まずはぼくがなぜここまでテトリスに執着しているのか、そして今回なぜGeminiを選んだのか、その背景からお話しさせてください。AI技術の進歩は本当に日進月歩で、昨日できなかったことが今日はできるようになっている、そんなエキサイティングな時代にぼくたちは生きています。この章では、過去の苦い経験と、そこから生まれた新たな希望について掘り下げていきます。

何度でも立ち上がる!過去の失敗から学ぶ教訓

ぼくのブログを長く読んでくださっている読者の方はご存知かもしれませんが、この「ブラウザテトリス」への挑戦は、今回が初めてではありません。過去にも数回、その当時の生成AIを使って開発を試みてきました。最初はコードが動くだけで感動したものの、ブロックが壁を突き抜けたり、回転すると画面外に消えてしまったりと、ゲームとして成立させるまでの道のりは本当に険しいものでした。

特に苦労したのは「スマホ対応」です。PCのキーボード操作なら実装は比較的簡単でも、スマホのタッチ操作になった途端に挙動がおかしくなるのです。ボタンを押しても反応しなかったり、逆に反応しすぎて連打になってしまったり。そんな経験を繰り返してきました。過去の挑戦の記録は、以下のリンクから一覧でご覧いただけます。ぼくがどれだけエラーメッセージと戦い、そして敗れてきたか、その歴史が詰まっています。

検索結果: テトリス -
検索結果ページです。

これらの失敗から学んだ最大の教訓は、「AIに丸投げしてはいけない」ということです。AIは優秀なパートナーですが、人間の意図を100%完璧に汲み取ってくれる魔法使いではありません。こちらが具体的な要件を定義し、エラーが起きたらその原因を一緒に探るような姿勢が必要なのだと痛感しました。

なぜアプリではなく「ブラウザ」にこだわるのか

世の中には素晴らしいテトリスのアプリがたくさんあります。公式のものから個人の開発者が作ったものまで、App StoreやGoogle Playを探せばいくらでも見つかります。それなのに、なぜぼくは頑なに「ブログ(WordPress)内で動くブラウザゲーム」にこだわるのでしょうか。それは、「誰でも、ダウンロード不要で、その場で遊べる」という手軽さに魅力を感じているからです。

アプリストアからダウンロードして、インストールして、初期設定をして…という手順は、ちょっとした暇つぶしにはハードルが高いこともありますよね。「あ、テトリスやりたいな」と思ったその瞬間に、ブラウザを開くだけで遊べる。記事を読みに来てくれた人が、ふと目に入った画面でそのまま遊び始められる。そんなシームレスな体験を作りたくて、ぼくはWordPressの記事内に埋め込めるHTML形式での開発を続けているのです。この「1ファイルで完結する」という制約こそが、開発の難易度を上げている要因でもあるのですが、同時にエンジニア魂(といってもAI頼みですが)を燃え上がらせるポイントでもあります。

期待の新星「Gemini 3 Pro」との出会い

今回、開発のパートナーに選んだのは、Googleの「Gemini」です。チャット内では「Gemini 3 Pro」として振る舞ってくれました。最近のAIの進化は目覚ましく、特にコーディング能力においては、コンテキスト(文脈)の理解力や、長いコードを破綻なく書き続ける能力が飛躍的に向上していると言われています。

しかし、正直に言えば不安がなかったわけではありません。これまでのAIも、初期段階のコードは書けても、修正を重ねるうちにコードがスパゲッティ状態になり、修正前の機能が壊れてしまったり、変数の定義を忘れてしまったりすることが多々ありました。「右を直せば左が壊れる」という、プログラミングあるあるの泥沼にハマるのではないか。そんな懸念を抱きつつも、期待を込めて最初のプロンプトを打ち込みました。「ブラウザで動くテトリスゲームを作ってください」と。ここから、Geminiとの長い対話が始まったのです。

爆速で形になるプロトタイプと「1ファイル完結」の美学

散らばったプログラムコードとテトリスブロックが、たった一つのHTMLファイルに魔法のように吸い込まれ整理される様子。WordPressで扱いやすいシングルファイル構造の概念図

Geminiとの共同作業は、驚きの連続でした。こちらの意図を先回りして読み取る能力、そして出力されるコードの美しさ。これまでのAIとは一線を画すその実力を見せつけられた初期段階の開発プロセスについてお話しします。特にWordPressユーザーにとって嬉しいポイントが満載でした。

驚異の初稿!いきなり動く感動体験

開発を始めてすぐに驚かされたのは、Geminiの初稿の精度の高さでした。最初の指示だけで、基本的なテトリスのルール(移動、回転、ライン消去)が実装されたコードが出力されたのです。「とりあえず動く」レベルではなく、「普通に遊べる」レベルのものが数秒で生成される体験は、何度味わっても鳥肌が立ちます。

従来のAIだと、最初はブロックが落ちてくるだけ、次は操作できるようにする、その次は消えるようにする…といった具合に、段階を踏んで指示を出す必要がありました。しかしGeminiは、最初のプロンプトから「テトリスを作るならこれが必要だよね」という要素を網羅的に実装してくれたのです。盤面の描画、テトリミノ(ブロック)の形状定義、衝突判定、そしてゲームループ。これらが最初から整合性の取れた状態で記述されていました。このスタートダッシュの速さが、その後の修正や機能追加に時間を割く余裕を生んでくれました。

WordPressユーザーに捧ぐ「シングルファイル」の魔法

しかも、ぼくがWordPressで使いやすいようにという意図を汲んでか、HTML、CSS、JavaScriptを別々のファイルにするのではなく、たった一つのHTMLファイルにまとめて記述してくれました。これは「Single File Mandate(単一ファイル指令)」と呼ばれる手法のようで、管理が非常に楽になります。

今回の完成コードの冒頭部分を見てください。

<!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>高機能テトリス (Audio修正版)</title>
    <style>
        /* CSSがここに全部入っている */
        :root {
            --bg-color: #202028;
            /* ... */
        }

このように、DOCTYPE宣言から始まり、<style>タグの中にCSS、そして<body>の後の<script>タグの中にJavaScriptまでが、1つのファイルに美しくパッケージングされています。これにより、WordPressの「カスタムHTML」ブロックにこのコードを貼り付けるだけで、即座にゲームが動くようになっているのです。外部ファイルを読み込む手間も、プラグインを入れる必要もありません。この手軽さこそが、ぼくが求めていたものでした。

UIデザインの妙!レスポンシブ対応への第一歩

機能だけでなく、見た目(UI)に関してもGeminiは優秀でした。最初から黒を基調としたモダンなデザインで、PCの大画面で見ても、スマホの縦長画面で見ても違和感のないレイアウトを提案してくれました。

特に、ゲーム画面の横に配置されたサイドパネルの実装が秀逸です。「Nextブロック」の表示エリアや、スコア、レベル、消したライン数を表示するエリアが、CSSのフレックスボックス(Flexbox)を使って綺麗に整列されています。

/* レイアウト */
.game-wrapper {
    display: flex;
    align-items: flex-start;
    gap: 20px;
}

この .game-wrapper クラスのおかげで、PCではメイン画面とサイドパネルが横並びになり、画面幅が狭いスマホでは自動的にレイアウトが調整されるような下地が最初から出来上がっていました。自分でCSSをいじって調整する手間がほとんど省けたのは、本当に助かりました。

スマホ対応の壁!操作性と誤動作との戦い

スマホブラウザゲーム特有の操作性の課題。タップ操作時に出現する邪魔なコピー&ペーストメニューをデジタルの盾でブロックし、快適なテトリス操作を実現する様子の3Dイラスト

PCでの動作は完璧でも、スマホで同じように遊べるかというと、話は別です。ここからが本当の戦いでした。タッチパネル特有の操作性の悪さや、ブラウザの仕様による予期せぬ挙動。これらを一つずつ潰していくプロセスは、まさに「開発」と呼ぶにふさわしいものでした。

ボタン連打でメニューが出る!?スマホ特有の落とし穴

スマホにはキーボードがありません。そのため、画面上に操作用のボタン(←、→、回転、↓)を表示させる必要があります。Geminiはすぐにボタンを配置してくれましたが、ここで大きな問題が発生しました。ゲームに熱中してボタンを連打したり、長押ししたりすると、スマホのブラウザがそれを「テキスト選択」や「長押しメニュー(コピーや共有などを選ぶメニュー)」の呼び出しだと誤認してしまうのです。

テトリスのようなアクションゲームで、操作のたびに「コピーしますか?」なんてメニューが出てきては、ゲームになりません。画面が拡大縮小してしまったり、ボタンがハイライトされて見づらくなったりと、スマホ特有のストレスが次々と襲いかかってきました。これはPCでのシミュレーションだけでは気づきにくい、実機ならではの問題点でした。

CSSで封じ込めろ!長押しメニュー撃退作戦

この問題に対し、GeminiはCSSプロパティを駆使した強力な対策を講じてくれました。具体的には、ユーザーによる選択操作や、長押しによるシステムメニューの呼び出しを無効化する記述です。

* {
    box-sizing: border-box;
    -webkit-touch-callout: none; /* iOS長押しメニュー無効化 */
    -webkit-user-select: none;
    user-select: none;
}

この user-select: none-webkit-touch-callout: none という魔法の呪文のおかげで、画面を激しくタップしても余計なメニューが出ることなく、ゲームに没頭できるようになりました。さらに、ボタン自体にも touch-action: none を指定し、ダブルタップによるズームなどのジェスチャーも無効化しています。これにより、アプリネイティブなゲームに近い操作感を実現することができたのです。

爽快感が段違い!「長押し早落ち」機能の実装

もう一つの課題が、落下ボタンの挙動です。PCのキーボードなら「↓キー」を押しっぱなしにすれば連続で落ちますが、スマホの画面上のボタンでは、通常「1回タップ=1回動作」となりがちです。これでは、ブロックを底まで運ぶのに何度もタップしなければならず、指が疲れてしまいます。

そこでぼくは「下ボタンを長押ししている間は、高速で落下するようにしたい」と要望を出しました。Geminiはこのリクエストに対し、setupTouchControls 関数内で巧みなイベント処理を実装してくれました。

// タッチコントローラー設定
function setupTouchControls() {
    const bindButton = (id, type) => {
        // ...省略
        btn.addEventListener('pointerdown', (e) => {
            // ...省略
            if (type === 'down') {
                isFastDropping = true;
            }
            // ...省略
        });

        const stopAction = (e) => {
            // ...省略
            if (type === 'down') {
                isFastDropping = false;
            }
        };
        // ...省略
    };
    // ...省略
}

ボタンを押した(pointerdown)瞬間に isFastDropping というフラグを立て、指を離した(pointeruppointerleave)瞬間にフラグを下ろす。そしてゲームのメインループ内でこのフラグを監視し、フラグが立っている間は落下速度を劇的に速くする。このロジックにより、スマホでも「長押しでダダダッ!」という爽快な早落ちを実現しました。

音が鳴らない!?Web Audio APIとCORSの悪夢

Web Audio APIでの音声再生がCORSエラーのファイアウォールに弾かれる様子と、HTML5 Audioタグを使用することでセキュリティの壁を回避し接続に成功した解決策の抽象画

視覚的な操作性はクリアしましたが、ゲームに欠かせない「音」の実装で、今回最大の壁にぶち当たりました。ブラウザのセキュリティ制限と、音声ファイルの扱いの難しさ。ここでのGeminiとのやり取りは、非常に技術的でスリリングなものでした。

高機能ゆえの罠?Web Audio APIのCORSエラー

最初は、遅延が少なく高機能な「Web Audio API」を使って音を鳴らす方針で進めていました。これは音のデータを細かく制御できるプロ向けのAPIなのですが、外部サーバーにある音声ファイル(ぼくのブログにアップロードしてあるMP3ファイル)を読み込もうとしたところで、「CORS(Cross-Origin Resource Sharing)」のエラーが発生してしまったのです。

簡単に言うと、ブラウザが「この音声ファイルは別のサイトのものだから、セキュリティ上の理由でプログラムからの詳細な解析や操作は許可できないよ!」とブロックしてしまったのです。コードは合っているのに音が鳴らない。コンソールには赤いエラーメッセージ。これはWeb開発者なら誰もが一度は経験する悪夢ですが、AIを使った開発でも例外ではありませんでした。

原点回帰の解決策!HTML5 Audioで壁を突破せよ

ここでGeminiが提案した解決策は、意外にもシンプルなものでした。「HTML5 Audio(昔ながらの<audio>タグの仕組み)」への原点回帰です。複雑なAPIを使ってデータを解析しようとするからブロックされるのであって、単に「音を再生する」だけなら、もっと単純な方法があるというわけです。

実際のコードを見てみましょう。AudioController クラスの中で、new Audio(url) を使ってシンプルにファイルを読み込んでいます。

// ==================================================
// オーディオマネージャー (HTML5 Audio版 - CORS回避)
// ==================================================
class AudioController {
    // ...省略
    init() {
        Object.keys(SOUND_URLS).forEach(key => {
            // ...省略
            if (key === 'bgm') {
                this.bgm = new Audio(url);
                // ...省略
            } else {
                // 効果音の元データ
                this.sounds[key] = new Audio(url);
                this.sounds[key].preload = 'auto'; // 事前読み込み
                // ...省略
            }
        });
    }
    // ...省略
}

この方法なら、画像を表示するのと同じくらいのセキュリティレベルで扱われるため、CORSの制限を受けにくくなります。さらに、遅延対策として preload を設定し、連打に対応するために cloneNode() を使って音声を複製してから再生するなどの工夫も凝らされています。これにより、スマホでも遅延なく、軽快に音が鳴るようになりました。

メンテナンス性も抜群!URL設定エリアの工夫

さらに感心したのは、コードのメンテナンス性です。音声ファイルのURLをコードの奥深くに埋め込むのではなく、冒頭に SOUND_URLS という設定オブジェクトとして切り出してくれました。

// ==================================================
// 音声URL設定エリア
// ==================================================
const SOUND_URLS = {
    bgm:     "[https://tokodomo.xyz/wp-content/uploads/2024/09/Fall-of-the-Blocks.mp3](https://tokodomo.xyz/wp-content/uploads/2024/09/Fall-of-the-Blocks.mp3)",
    move:    "", // 指定なし
    rotate:  "[https://tokodomo.xyz/wp-content/uploads/2024/09/rotate.mp3](https://tokodomo.xyz/wp-content/uploads/2024/09/rotate.mp3)",
    drop:    "[https://tokodomo.xyz/wp-content/uploads/2024/09/drop.mp3](https://tokodomo.xyz/wp-content/uploads/2024/09/drop.mp3)", // 積み重なった時
    clear:   "[https://tokodomo.xyz/wp-content/uploads/2024/09/line-clear.mp3](https://tokodomo.xyz/wp-content/uploads/2024/09/line-clear.mp3)", // 1-3段消去
    tetris:  "[https://tokodomo.xyz/wp-content/uploads/2024/09/tetris.mp3](https://tokodomo.xyz/wp-content/uploads/2024/09/tetris.mp3)", // 4段消去
    gameover:""  // 指定なし
};

完成したVer.3テトリスと、これからのあんちゃんブログ

スマホ上でバグなく完璧に動作する完成版テトリス Ver.3。成功を祝う紙吹雪と右肩上がりの成長グラフに囲まれた、ブラウザゲーム開発プロジェクト完了の祝賀イメージ

数々の困難を乗り越え、ついに完成した「Ver.3」。それは単なるゲームプログラム以上の、AIとの対話の結晶でした。最後に、完成したものの総括と、これからの展望についてお話しします。

ついに完成!PC・スマホ完全対応のテトリス

こうして完成したのが、今回の「Ver.3 テトリス」です。その特徴を改めてまとめると、以下のようになります。

  1. 1ファイル完結: WordPressの記事にコピペするだけで動く、究極のポータビリティ。
  2. 完全レスポンシブ: PCの横長画面でも、スマホの縦長画面でもレイアウトが崩れず最適化される。
  3. 快適な操作性: スマホでの長押し早落ち、連打対応、誤操作防止機能の実装。
  4. 遅延なきサウンド: CORS問題をクリアし、BGMと効果音が気持ちよく鳴り響く。

まさに、ぼくが長年追い求めていた「ブログで遊べる理想のテトリス」が形になりました。

AIとの「対話」が鍵!エラー解決の新しい形

今回の開発を通して強く感じたのは、AI(Gemini)に対して「何が起きていて、どうしたいか」を正確に伝えることの重要性です。特に音の問題では、単に「音が鳴りません」と伝えるだけでは解決しませんでした。「外部URLを使っている」「CORSエラーが出ているかもしれない」という状況をコードベースで理解してもらうことで、AIも「それならHTML5 Audioの方が安全です」と適切な解決策を導き出すことができました。

AIは魔法の杖ではありませんが、言葉を尽くして対話すれば、最強のパートナーになってくれます。エラーが出た時こそ、AIとの対話のチャンスだと捉える余裕が、開発成功の鍵なのかもしれません。

次なる野望は?ランキング機能と保存への道

Ver.3で基本的なゲームプレイは完成しましたが、欲を言えばキリがありません。「ハイスコアを保存したい」「ランキング機能を作って読者同士で競わせたい」など、夢は広がります。

ただ、データを保存するにはデータベースとの連携が必要になり、今回のような「1ファイル完結」の美しさが損なわれる可能性もあります。WordPressの既存の仕組みとうまく連携させる方法を、またGeminiと相談しながら模索していきたいと思います。

今回のテトリス開発の成功は、ぼくにとって大きな自信になりました。これからも、トコドモブログではAIと共に、ブラウザで動く楽しい仕掛けを研究し続けていきます。みなさんもぜひ、記事に埋め込まれたテトリスで遊んでみてくださいね。それでは、また!

あんちゃん
あんちゃん

おっと、わすれちゃいけない、コード全部を貼り付けるよ!!

<!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>高機能テトリス (Audio修正版)</title>
    <style>
        :root {
            --bg-color: #202028;
            --ui-color: #fff;
            --accent-color: #4a4a55;
            --panel-bg: #30303a;
            --btn-bg: rgba(255, 255, 255, 0.15);
            --btn-active: rgba(255, 255, 255, 0.4);
        }

        * {
            box-sizing: border-box;
            -webkit-touch-callout: none; /* iOS長押しメニュー無効化 */
            -webkit-user-select: none;
            user-select: none;
        }

        body {
            background: 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;
            gap: 20px;
        }

        .game-container {
            position: relative;
            border: 2px solid var(--accent-color);
            box-shadow: 0 0 20px rgba(0,0,0,0.5);
        }

        canvas {
            display: block;
            background-color: #000;
        }

        .side-panel {
            display: flex;
            flex-direction: column;
            gap: 20px;
            width: 100px;
        }

        .next-block-container {
            background: var(--panel-bg);
            border: 2px solid var(--accent-color);
            padding: 10px;
            display: flex;
            flex-direction: column;
            align-items: center;
        }

        .label {
            font-size: 0.8rem;
            color: #aaa;
            margin-bottom: 5px;
            text-transform: uppercase;
        }

        .stats-container {
            display: flex;
            flex-direction: column;
            gap: 10px;
            text-align: left;
        }

        .stat-item {
            background: var(--panel-bg);
            padding: 8px;
            border-radius: 4px;
            border: 1px solid var(--accent-color);
        }

        .stat-value {
            font-size: 1.1rem;
            font-weight: bold;
        }

        /* オーバーレイ */
        #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;
            cursor: pointer;
            z-index: 10;
        }

        #overlay h1 {
            font-size: 2rem;
            margin: 0 0 20px 0;
            color: #ff0055;
            text-shadow: 2px 2px #fff;
        }

        #status-message {
            font-size: 1.2rem;
            animation: blink 1.5s infinite;
        }

        @keyframes blink {
            50% { opacity: 0; }
        }

        /* コントローラー */
        .controls {
            display: none; /* JSでモバイル判定またはCSSで表示 */
            margin-top: 20px;
            width: 100%;
            max-width: 340px;
            gap: 10px;
            justify-content: center;
            flex-wrap: wrap;
        }

        @media (max-width: 768px) {
            .controls {
                display: grid;
                grid-template-columns: 1fr 1fr 1fr;
                grid-template-rows: 1fr 1fr;
                gap: 12px;
                padding: 0 20px;
            }
            .game-wrapper {
                gap: 10px;
            }
            .side-panel {
                width: 80px;
            }
        }

        .btn {
            background: var(--btn-bg);
            border: 1px solid rgba(255, 255, 255, 0.2);
            color: white;
            padding: 20px 0;
            border-radius: 12px;
            font-size: 1.8rem;
            cursor: pointer;
            display: flex;
            justify-content: center;
            align-items: center;
            transition: transform 0.05s, background 0.1s;
            /* スマホ用重要設定 */
            touch-action: none; 
            -webkit-tap-highlight-color: transparent;
        }

        .btn:active, .btn.active {
            background: var(--btn-active);
            transform: scale(0.92);
        }

        .btn-rotate { grid-column: 2; grid-row: 1; background: rgba(255, 200, 0, 0.2); }
        .btn-left { grid-column: 1; grid-row: 2; }
        .btn-down { grid-column: 2; grid-row: 2; }
        .btn-right { grid-column: 3; grid-row: 2; }

    </style>
</head>
<body>

    <div class="game-wrapper">
        <div class="game-container">
            <canvas id="tetris" width="240" height="400"></canvas>
            <div id="overlay">
                <h1>TETRIS</h1>
                <p id="status-message">Click to Start</p>
            </div>
        </div>

        <div class="side-panel">
            <div class="next-block-container">
                <div class="label">Next</div>
                <canvas id="next" width="100" height="100"></canvas>
            </div>

            <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">Lines</div>
                    <div id="lines" class="stat-value">0</div>
                </div>
            </div>
        </div>
    </div>

    <div class="controls">
        <div class="btn btn-rotate" id="btn-rotate"></div>
        <div class="btn btn-left" id="btn-left"></div>
        <div class="btn btn-down" id="btn-down"></div>
        <div class="btn btn-right" id="btn-right"></div>
    </div>

    <script>
        // ==================================================
        // 音声URL設定エリア
        // ==================================================
        const SOUND_URLS = {
            bgm:     "https://tokodomo.xyz/wp-content/uploads/2024/09/Fall-of-the-Blocks.mp3",
            move:    "", // 指定なし
            rotate:  "https://tokodomo.xyz/wp-content/uploads/2024/09/rotate.mp3",
            drop:    "https://tokodomo.xyz/wp-content/uploads/2024/09/drop.mp3", // 積み重なった時
            clear:   "https://tokodomo.xyz/wp-content/uploads/2024/09/line-clear.mp3", // 1-3段消去
            tetris:  "https://tokodomo.xyz/wp-content/uploads/2024/09/tetris.mp3", // 4段消去
            gameover:""  // 指定なし
        };

        // ==================================================
        // オーディオマネージャー (HTML5 Audio版 - CORS回避)
        // ==================================================
        class AudioController {
            constructor() {
                this.sounds = {};
                this.bgm = null;
                this.isMuted = false;
            }

            init() {
                // オブジェクトの生成
                Object.keys(SOUND_URLS).forEach(key => {
                    const url = SOUND_URLS[key];
                    if (!url) return;

                    if (key === 'bgm') {
                        this.bgm = new Audio(url);
                        this.bgm.loop = true;
                        this.bgm.volume = 0.4; // BGM音量
                    } else {
                        // 効果音の元データ
                        this.sounds[key] = new Audio(url);
                        this.sounds[key].preload = 'auto'; // 事前読み込み
                        this.sounds[key].volume = 0.6; // SE音量
                    }
                });
            }

            play(name) {
                if (this.isMuted) return;
                const srcAudio = this.sounds[name];
                
                if (srcAudio) {
                    // 連打対応: クローンを作成して再生
                    // これにより前の音が消えずに重なって鳴ります
                    const clone = srcAudio.cloneNode();
                    clone.volume = srcAudio.volume;
                    // 再生エラー(ユーザー操作前の自動再生ブロック等)をキャッチ
                    clone.play().catch(e => {
                        console.warn(`Sound play error (${name}):`, e);
                    });
                }
            }

            playBGM() {
                if (this.isMuted || !this.bgm) return;
                this.bgm.play().catch(e => {
                    console.warn("BGM play error:", e);
                });
            }

            stopBGM() {
                if (this.bgm) {
                    this.bgm.pause();
                    this.bgm.currentTime = 0;
                }
            }
        }

        const audio = new AudioController();
        // 初期化実行
        audio.init();

        // ==================================================
        // ゲーム基本設定
        // ==================================================
        const canvas = document.getElementById('tetris');
        const context = canvas.getContext('2d');
        const nextCanvas = document.getElementById('next');
        const nextContext = nextCanvas.getContext('2d');
        
        context.scale(20, 20);
        nextContext.scale(20, 20);

        const ui = {
            score: document.getElementById('score'),
            lines: document.getElementById('lines'),
            level: document.getElementById('level'),
            overlay: document.getElementById('overlay'),
            status: document.getElementById('status-message')
        };

        const pieces = 'ILJOTSZ';
        const colors = [null, '#FF0D72', '#0DC2FF', '#0DFF72', '#F538FF', '#FF8E0D', '#FFE138', '#3877FF'];

        function createMatrix(w, h) {
            const matrix = [];
            while (h--) matrix.push(new Array(w).fill(0));
            return matrix;
        }

        function createPiece(type) {
            if (type === 'I') return [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]];
            if (type === 'L') return [[0, 2, 0], [0, 2, 0], [0, 2, 2]];
            if (type === 'J') return [[0, 3, 0], [0, 3, 0], [3, 3, 0]];
            if (type === 'O') return [[4, 4], [4, 4]];
            if (type === 'Z') return [[5, 5, 0], [0, 5, 5], [0, 0, 0]];
            if (type === 'S') return [[0, 6, 6], [6, 6, 0], [0, 0, 0]];
            if (type === 'T') return [[0, 7, 0], [7, 7, 7], [0, 0, 0]];
        }

        function drawMatrix(matrix, offset, ctx = context) {
            matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        ctx.fillStyle = colors[value];
                        ctx.fillRect(x + offset.x, y + offset.y, 1, 1);
                        ctx.lineWidth = 0.05;
                        ctx.strokeStyle = 'rgba(255,255,255,0.5)';
                        ctx.strokeRect(x + offset.x, y + offset.y, 1, 1);
                    }
                });
            });
        }

        function draw() {
            context.fillStyle = '#000';
            context.fillRect(0, 0, canvas.width, canvas.height);
            drawMatrix(arena, {x: 0, y: 0});
            drawMatrix(player.matrix, player.pos);
        }

        function drawNext() {
            nextContext.fillStyle = '#000';
            nextContext.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
            if (!nextPiece) return;
            const offsetX = (5 - nextPiece[0].length) / 2;
            const offsetY = (5 - nextPiece.length) / 2;
            drawMatrix(nextPiece, {x: offsetX, y: offsetY}, nextContext);
        }

        // ==================================================
        // ゲームロジック
        // ==================================================
        function merge(arena, player) {
            player.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) arena[y + player.pos.y][x + player.pos.x] = value;
                });
            });
        }

        function rotate(matrix, dir) {
            for (let y = 0; y < matrix.length; ++y) {
                for (let x = 0; x < y; ++x) {
                    [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
                }
            }
            if (dir > 0) matrix.forEach(row => row.reverse());
            else matrix.reverse();
        }

        function collide(arena, player) {
            const m = player.matrix;
            const o = player.pos;
            for (let y = 0; y < m.length; ++y) {
                for (let x = 0; x < m[y].length; ++x) {
                    if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) {
                        return true;
                    }
                }
            }
            return false;
        }

        function arenaSweep() {
            let rowCount = 1;
            let clearedLinesCount = 0;
            
            outer: for (let y = arena.length -1; y > 0; --y) {
                for (let x = 0; x < arena[y].length; ++x) {
                    if (arena[y][x] === 0) continue outer;
                }
                const row = arena.splice(y, 1)[0].fill(0);
                arena.unshift(row);
                ++y;
                
                player.score += rowCount * 10;
                player.lines += 1;
                rowCount *= 2;
                clearedLinesCount++;
            }

            // 音の判定
            if (clearedLinesCount > 0) {
                if (clearedLinesCount >= 4) {
                    audio.play('tetris'); // 4段消去
                } else {
                    audio.play('clear'); // 1-3段消去
                }
            }
        }

        function playerDrop() {
            player.pos.y++;
            if (collide(arena, player)) {
                player.pos.y--;
                merge(arena, player);
                audio.play('drop'); // 積み重なり音
                playerReset();
                arenaSweep();
                updateScore();
            }
            dropCounter = 0;
        }

        function playerMove(offset) {
            player.pos.x += offset;
            if (collide(arena, player)) {
                player.pos.x -= offset;
            } else {
                audio.play('move');
            }
        }

        function playerRotate(dir) {
            const pos = player.pos.x;
            let offset = 1;
            rotate(player.matrix, dir);
            while (collide(arena, player)) {
                player.pos.x += offset;
                offset = -(offset + (offset > 0 ? 1 : -1));
                if (offset > player.matrix[0].length) {
                    rotate(player.matrix, -dir);
                    player.pos.x = pos;
                    return;
                }
            }
            audio.play('rotate');
        }

        let nextPiece = null;
        function playerReset() {
            if (nextPiece === null) nextPiece = createPiece(pieces[pieces.length * Math.random() | 0]);
            player.matrix = nextPiece;
            nextPiece = createPiece(pieces[pieces.length * Math.random() | 0]);
            drawNext();
            player.pos.y = 0;
            player.pos.x = (arena[0].length / 2 | 0) - (player.matrix[0].length / 2 | 0);

            if (collide(arena, player)) {
                isGameOver = true;
                audio.stopBGM();
                audio.play('gameover');
                ui.status.innerText = "Click to Restart";
                ui.overlay.querySelector('h1').innerText = "GAME OVER";
                ui.overlay.style.display = 'flex';
            }
        }

        function updateScore() {
            ui.score.innerText = player.score;
            ui.lines.innerText = player.lines;
            const level = Math.floor(player.score / 100) + 1;
            ui.level.innerText = level;
            // レベルアップの速度調整
            const newInterval = 1000 - (level - 1) * 100;
            baseDropInterval = Math.max(100, newInterval);
        }

        // ==================================================
        // ループ&入力制御
        // ==================================================
        let dropCounter = 0;
        let baseDropInterval = 1000;
        let lastTime = 0;
        let isGameOver = true;
        
        let isFastDropping = false;
        const fastDropInterval = 40; // 高速落下速度(ms)

        function update(time = 0) {
            if (isGameOver) return;

            const deltaTime = time - lastTime;
            lastTime = time;

            dropCounter += deltaTime;
            
            const currentInterval = isFastDropping ? fastDropInterval : baseDropInterval;

            if (dropCounter > currentInterval) {
                playerDrop();
            }

            draw();
            requestAnimationFrame(update);
        }

        const arena = createMatrix(12, 20);
        const player = { pos: {x: 0, y: 0}, matrix: null, score: 0, lines: 0 };

        // コンテキストメニュー無効化 (長押しメニュー防止)
        window.addEventListener('contextmenu', e => {
            e.preventDefault();
            return false;
        }, { passive: false });

        // キーボード操作
        document.addEventListener('keydown', event => {
            if (isGameOver) return;
            if (event.keyCode === 37) playerMove(-1);
            else if (event.keyCode === 39) playerMove(1);
            else if (event.keyCode === 40) {
                playerDrop();
            }
            else if (event.keyCode === 81) playerRotate(-1);
            else if (event.keyCode === 87 || event.keyCode === 38) playerRotate(1);
        });

        // タッチコントローラー設定
        function setupTouchControls() {
            const bindButton = (id, type) => {
                const btn = document.getElementById(id);
                
                btn.addEventListener('pointerdown', (e) => {
                    e.preventDefault();
                    if (isGameOver) return;
                    
                    btn.classList.add('active');

                    if (type === 'down') {
                        isFastDropping = true;
                    } else if (type === 'left') {
                        playerMove(-1);
                    } else if (type === 'right') {
                        playerMove(1);
                    } else if (type === 'rotate') {
                        playerRotate(1);
                    }
                });

                const stopAction = (e) => {
                    e.preventDefault();
                    btn.classList.remove('active');
                    if (type === 'down') {
                        isFastDropping = false;
                    }
                };

                btn.addEventListener('pointerup', stopAction);
                btn.addEventListener('pointerleave', stopAction);
                btn.addEventListener('pointercancel', stopAction);
            };

            bindButton('btn-left', 'left');
            bindButton('btn-right', 'right');
            bindButton('btn-rotate', 'rotate');
            bindButton('btn-down', 'down');
        }

        setupTouchControls();

        // ゲーム開始処理
        function startGame() {
            arena.forEach(row => row.fill(0));
            player.score = 0;
            player.lines = 0;
            nextPiece = null;
            isFastDropping = false;
            
            updateScore();
            playerReset();
            
            isGameOver = false;
            ui.overlay.style.display = 'none';
            
            // ユーザー操作の直後なので再生が許可されるはず
            audio.playBGM(); 
            update();
        }

        ui.overlay.addEventListener('click', startGame);

        // 初期描画
        context.fillStyle = '#000';
        context.fillRect(0, 0, canvas.width, canvas.height);

    </script>
</body>
</html>

コメント

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