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

【Gemini 3 Pro】サーバー代0円!Googleスプレッドシートで動く「ランキング付きテトリス」開発全記録

テトリスver5 ランキング機能付きGoogleスプレッドシートGASGoogleActionScript Gemini
スポンサーリンク
スポンサーリンク

皆さん、こんにちは。あんちゃんです。

これまでこのブログでは、生成AI「Gemini」と共に、WordPressの記事内(ブラウザ)だけで動くテトリスの開発に挑戦してきました。

前回までの「Ver.3」で、スマホでのタッチ操作や、鬼門だった音声再生の問題はクリアしました。普通に遊べるゲームにはなったんです。でも、何かが足りない。そう、「競争」です。自分が出したハイスコアを誰かに自慢したい、他の読者と競い合いたい。ゲームの寿命を延ばすのはいつだって「ランキング機能」ですよね。

しかし、ランキングを作るには通常、データベースやサーバーの契約が必要で、コストも技術的ハードルも跳ね上がります。「1ファイル完結でコピペで動く」というぼくの美学に反するのです。

そこで今回は、Googleの最新AI「Gemini 3 Pro」に無茶振りをしました。「Googleスプレッドシートをデータベースにして、無料でランキングを作ってくれ」と。

結果、CORSエラーという巨大な壁にぶち当たり、Loading地獄を味わいながらも、ついに**「完全無料・サーバーレス・リアルタイムランキング」**を搭載した「Ver.5」が完成しました。今回はその泥臭い開発の経緯と、完成した全コードを公開します。

あんちゃん
あんちゃん

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

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

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

夢の「ランキング機能」と立ちはだかる「サーバーの壁」

Gemini 3 Proと協力して過去のテトリス開発の失敗を乗り越え、バグのない完成版へと進む開発者の3Dイラスト

ゲームを作る楽しさに目覚めたぼくですが、どうしても超えられない壁がありました。それが「データの保存」です。WordPressの記事内で動くJavaScriptのゲームは、ページを更新すればスコアが消えてしまいます。これでは「暇つぶし」の域を出ません。

なぜ今までランキングを作らなかったのか

理由は単純で、「お金と手間がかかるから」です。通常、オンラインランキングを実装するには、MySQLなどのデータベースを用意し、PHPやNode.jsでサーバー側のプログラムを書く必要があります。

レンタルサーバーを借りれば月額費用がかかりますし、WordPressのデータベースを直接いじるのは、素人がやるにはリスクが高すぎて、ブログごと破壊してしまう恐怖がありました。「記事にコードを貼るだけ」という手軽さを維持しながら、データを外部に保存する。そんな都合の良い方法は存在しないと思っていました。

Geminiが提示した「Google Apps Script」という抜け道

「サーバーは借りたくない。でもランキングは作りたい」。そんなワガママをGeminiに相談したところ、返ってきた答えは**「Googleスプレッドシートを使いましょう」**というものでした。

Googleスプレッドシートには「Google Apps Script (GAS)」というプログラム機能がついています。これを使えば、スプレッドシートを簡易的なWeb API(データの受付窓口)として公開できるというのです。

  • データベース: Googleスプレッドシート(無料)
  • サーバー処理: Google Apps Script(無料)
  • フロントエンド: WordPressの記事内HTML(無料)

これなら、完全無料でランキングシステムが構築できます。「これだ!」と思い、早速開発に着手しました。

最初のプロトタイプと「動くけど動かない」現象

Geminiは瞬時にコードを生成してくれました。ゲームオーバー時に名前を入力し、「送信」ボタンを押すと、GAS経由でスプレッドシートに行が追加される。

実際にやってみると、スプレッドシートには確かに「名前:あんちゃん、スコア:1000」と記録されています。しかし、肝心のゲーム画面は「Loading…」と表示されたままフリーズし、ランキング表が表示されません。ここから、長い長い戦いが始まりました。

泥沼の「CORSエラー」と「Loading地獄」との戦い

ブラウザゲーム開発で発生するCORSエラーやローディング問題を、Google Apps Scriptの設定で回避する様子のイメージ図

「データは送れているのに、画面に反映されない」。この奇妙な現象の原因は、Web開発者が恐れる**「CORS(Cross-Origin Resource Sharing)」**というセキュリティの壁でした。

サーバーからの返事をブラウザが拒否する

簡単に説明すると、ぼくのブログ(tokodomo.xyz)から、Googleのサーバー(script.google.com)へデータを送ることは「一方通行」なら許可されています。しかし、Googleから「書き込み完了しましたよ、最新のランキングはこれですよ」という「返事」を受け取る際に、ブラウザが「待った! 別のサイトからのデータは怪しいから読み込ませないぞ!」とブロックしてしまうのです。

これが原因で、プログラムはずっと「Googleからの返事」を待ち続け、画面には永遠に「Loading…」が表示されることになりました。

エラーを無視する「投げっぱなし送信」作戦

Geminiと何度もやり取りを重ね、エラーログを解析しました。「JSON形式を変えてみて」「ヘッダーを追加してみて」など試行錯誤しましたが、ブラウザのセキュリティは鉄壁です。

そこでGeminiが提案したのが、mode: 'no-cors' という禁じ手でした。これは「返事を期待しない。とにかく送りつけるだけでいいから、エラーを出さないでくれ」という通信モードです。

「送ったかどうかの確認もせず、成功したとみなす」。プログラミング的には行儀の悪い方法ですが、個人ブログのミニゲームにおいては、これこそが最適解でした。

ユーザー体験を損なわないためのUI改善

しかし、「投げっぱなし」には欠点があります。送信完了の合図がないため、いつランキングを更新していいか分からないのです。

そこで、「送信ボタンを押したら、即座に『送信完了』と表示して入力フォームを消す」という演出を入れました。裏側ではまだ通信中かもしれませんが、ユーザーには「終わった」と思わせる。そして、数秒のタイムラグを置いてからランキングをこっそり再読み込みする。

さらに、通信中は画面左側のランキングパネルに、帯状の「Loading…」アニメーションを表示するようにしました。これにより、「フリーズしているわけではなく、通信中なんだな」と視覚的に伝わるようになり、ストレスが劇的に改善されました。

PCもスマホも完璧に。進化した3カラムレイアウト

PCの3カラムレイアウトとスマホの縦長画面の両方に完全対応した、レスポンシブデザインのテトリスゲーム画面UI

ランキング機能の実装に合わせて、画面レイアウトも大幅に進化させました。これまでの「ゲーム画面の横にちょこっと文字が出る」だけのデザインから、情報を整理した「3カラム構成」へと刷新しました。

情報を整理整頓した「コックピット」のような画面

PCで見ると、画面は以下の3つに分かれています。

  1. 左パネル(Ranking): 常に最新のトップ10が表示されるエリア。自分が入賞するとピカッと光ります。
  2. 中央パネル(Main): テトリスのプレイ画面。
  3. 右パネル(Status): 次のブロックやスコア、レベルを表示するエリア。

これにより、プレイ中も「あ、今のスコアなら5位に入れるかも!」と横目で確認でき、モチベーションが維持できます。

スマホでは「縦長」に自動変形

もちろん、スマホ対応も抜かりありません。CSSのフレックスボックス(Flexbox)を駆使し、画面幅が狭くなると自動的にレイアウトが組み替わります。

スマホでは、まず「ゲーム画面(中央)」が一番上に来て、その下に「ステータス(右)」、さらにその下に「ランキング(左)」が配置される縦長レイアウトになります。コントローラーのボタン配置も調整し、指で隠れて盤面が見えなくなることもありません。

設置方法と全コード公開

GoogleスプレッドシートをデータベースとしてWordPressと連携させ、無料でランキング機能を実装する仕組みの概念図

それでは、完成した「Ver.5.4」のコードを公開します。設置には「Googleスプレッドシートの準備」と「WordPressへの貼り付け」の2ステップが必要です。少し手順が多いですが、コピペで終わるので挑戦してみてください。

ステップ1:GoogleスプレッドシートとGASの準備

まず、ランキングデータを保存する場所を作ります。

  1. Googleドライブで新規スプレッドシートを作成します。
  2. メニューの「拡張機能」から**「Apps Script」**を開きます。
  3. エディタが開くので、元々あるコードを消して、以下のコードを貼り付けます。

JavaScript

// GAS側のコード(コピペ用)
function doGet(e) {
  return handleRequest(e);
}

function doPost(e) {
  return handleRequest(e);
}

function handleRequest(e) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
  const params = e.parameter;
  const action = params.action;
  
  // JSON形式で返す準備
  const output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);

  try {
    if (action === 'save') {
      let name = params.name;
      let score = params.score;

      // JSON送信対応
      if (!name && e.postData && e.postData.contents) {
        try {
          const json = JSON.parse(e.postData.contents);
          name = json.name;
          score = json.score;
        } catch (e) {}
      }

      if (name && score) {
        if (name.length > 10) name = name.substring(0, 10);
        name = name.replace(/[<>]/g, ''); // 簡易サニタイズ
        
        sheet.appendRow([new Date(), name, parseInt(score)]);
        output.setContent(JSON.stringify({ status: 'success' }));
      } else {
        output.setContent(JSON.stringify({ status: 'error', message: 'No data' }));
      }
      return output;

    } else if (action === 'get') {
      const lastRow = sheet.getLastRow();
      let ranking = [];
      
      if (lastRow >= 2) {
        const data = sheet.getRange(2, 2, lastRow - 1, 2).getValues();
        data.sort((a, b) => b[1] - a[1]); // スコア降順ソート
        ranking = data.slice(0, 10).map(row => ({ name: row[0], score: row[1] }));
      }
      
      output.setContent(JSON.stringify({ status: 'success', ranking: ranking }));
      return output;
    }
    
    output.setContent(JSON.stringify({ status: 'error', message: 'Invalid action' }));
    return output;

  } catch (err) {
    output.setContent(JSON.stringify({ status: 'error', message: err.toString() }));
    return output;
  }
}
  1. 貼り付けたら、右上の「デプロイ」ボタンから**「新しいデプロイ」**を選択します。
  2. 「種類の選択」で「ウェブアプリ」を選びます。
  3. ここが最重要です: 「アクセスできるユーザー」を**「全員 (Anyone)」**に設定してください。これをしておかないと、読者がランキングを見られません。
  4. デプロイして発行された**「ウェブアプリのURL」**をコピーしておきます。

ステップ2:WordPressへのコード貼り付け

次に、WordPressの記事作成画面で「カスタムHTML」ブロックを追加し、以下のコードを貼り付けます。

※コードの最初の方にある const RANKING_API_URL = “”; のダブルクォーテーションの中に、先ほどコピーしたURLを貼り付けてください。

HTML

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>テトリス Ver.5.4</title>
    <style>
        :root {
            --bg-color: #202028;
            --ui-color: #fff;
            --accent-color: #4a4a55;
            --panel-bg: rgba(48, 48, 58, 0.95);
            --btn-bg: rgba(255, 255, 255, 0.15);
            --btn-active: rgba(255, 255, 255, 0.4);
            --rank-gold: #ffd700;
            --rank-silver: #c0c0c0;
            --rank-bronze: #cd7f32;
            --neon-blue: #00f3ff;
            --neon-pink: #ff0055;
            --neon-green: #0aff00;
        }

        * {
            box-sizing: border-box;
            -webkit-touch-callout: none;
            -webkit-user-select: none;
            user-select: none;
        }

        body {
            background: 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-x: hidden;
            touch-action: none;
        }

        /* 3カラムレイアウト */
        .game-wrapper {
            display: flex;
            align-items: flex-start;
            justify-content: center;
            gap: 20px;
            padding: 10px;
            width: 100%;
            max-width: 800px;
        }

        /* 左パネル:ランキング */
        .ranking-panel {
            width: 200px;
            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: 420px;
            overflow: hidden;
            position: relative;
        }

        .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;
        }
        #reload-ranking:active { background: rgba(0, 243, 255, 0.3); }

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

        /* Loading Overlay */
        #ranking-loading-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(32, 32, 40, 0.85);
            display: none;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 10;
            backdrop-filter: blur(2px);
        }

        .loading-band {
            width: 100%;
            padding: 15px 0;
            background: linear-gradient(90deg, transparent, rgba(0, 243, 255, 0.2), transparent);
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 10px;
        }

        .loading-text {
            color: var(--neon-blue);
            font-weight: bold;
            font-size: 1.1rem;
            letter-spacing: 1px;
            text-shadow: 0 0 5px var(--neon-blue);
        }

        .loading-spinner {
            width: 20px;
            height: 20px;
            border: 3px solid rgba(0, 243, 255, 0.3);
            border-top: 3px solid var(--neon-blue);
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

        #ranking-loading-overlay.error .loading-text { color: var(--neon-pink); text-shadow: 0 0 5px var(--neon-pink); }
        #ranking-loading-overlay.error .loading-spinner { display: none; }
        #ranking-loading-overlay.error .loading-band { background: linear-gradient(90deg, transparent, rgba(255, 0, 85, 0.2), transparent); }

        /* 中央:ゲームコンテナ */
        .game-container {
            position: relative;
            border: 2px solid var(--accent-color);
            box-shadow: 0 0 20px rgba(0,0,0,0.5);
            background: #000;
            width: 240px;
            height: 400px;
            flex-shrink: 0;
        }

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

        /* 右パネル:ステータス */
        .side-panel {
            display: flex;
            flex-direction: column;
            gap: 15px;
            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;
        }

        .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.92);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 10;
            padding: 20px;
            text-align: center;
        }

        #overlay h1 {
            font-size: 2rem;
            margin: 0 0 10px 0;
            color: var(--neon-pink);
            text-shadow: 2px 2px #fff;
        }

        #status-message {
            font-size: 1.2rem;
            margin-bottom: 20px;
            min-height: 1.5em;
        }

        /* 入力フォーム */
        #ranking-form {
            display: none;
            flex-direction: column;
            gap: 10px;
            width: 100%;
            max-width: 180px;
            margin-bottom: 20px;
        }

        #player-name {
            padding: 10px;
            border-radius: 4px;
            border: 1px solid #fff;
            background: #333;
            color: #fff;
            text-align: center;
            font-size: 1rem;
            user-select: text;
            -webkit-user-select: text;
        }

        #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;
        }
        #submit-score:hover { background: #d00045; }
        #submit-score:disabled { background: #555; cursor: not-allowed; }

        #restart-btn {
            padding: 10px 25px;
            background: transparent;
            border: 2px solid #fff;
            color: #fff;
            border-radius: 25px;
            cursor: pointer;
            font-size: 1rem;
            font-weight: bold;
            animation: blink 2s infinite;
        }
        #restart-btn:hover { background: rgba(255,255,255,0.1); }

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

        /* スマホ用コントローラー */
        .controls {
            display: none;
            margin-top: 15px;
            width: 100%;
            max-width: 340px;
            gap: 10px;
            justify-content: center;
            flex-wrap: wrap;
        }

        @media (max-width: 768px) {
            .game-wrapper { flex-wrap: wrap; justify-content: center; }
            .ranking-panel {
                order: 3;
                width: 100%;
                max-width: 340px; 
                height: 200px;
                margin-top: 10px;
            }
            .game-container { order: 1; }
            .side-panel { order: 2; width: 80px; }
            .controls {
                display: grid;
                grid-template-columns: 1fr 1fr 1fr;
                grid-template-rows: 1fr 1fr;
                gap: 12px;
                padding: 0 10px;
            }
        }

        .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;
            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="ranking-panel">
            <div class="ranking-header">
                <div class="ranking-title">Ranking</div>
                <button id="reload-ranking" title="Reload">↻</button>
            </div>
            <ul id="ranking-list"></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="tetris" width="240" height="400"></canvas>
            <div id="overlay">
                <h1>TETRIS</h1>
                <p id="status-message">Click Start</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 GAME</button>
            </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>
        // ★ここにGASのURLを貼り付けてください★
        const RANKING_API_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",
            tetris:  "https://tokodomo.xyz/wp-content/uploads/2024/09/tetris.mp3",
            gameover:""
        };

        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.loadingText = this.loadingOverlay.querySelector('.loading-text');
                this.lastSubmitName = "";
                
                this.submitBtn.addEventListener('click', () => this.submitScore());
                this.reloadBtn.addEventListener('click', () => this.fetchRanking());
            }

            showLoading(msg, isError = false) {
                this.loadingText.innerText = msg;
                if (isError) this.loadingOverlay.classList.add('error');
                else this.loadingOverlay.classList.remove('error');
                this.loadingOverlay.style.display = 'flex';
            }

            hideLoading() { this.loadingOverlay.style.display = 'none'; }

            async fetchRanking() {
                if (!RANKING_API_URL) {
                    this.list.innerHTML = '<li style="padding:10px;text-align:center;color:#ff5555;">URL未設定</li>';
                    return;
                }
                this.showLoading('Loading...');
                this.list.style.opacity = '0.3';
                try {
                    let cleanUrl = RANKING_API_URL.trim().replace(/\?$/, '');
                    const url = `${cleanUrl}?action=get&t=${new Date().getTime()}`;
                    const response = await fetch(url);
                    if (!response.ok) throw new Error("Net Error");
                    const data = await response.json();
                    if (data.status === 'success') {
                        this.renderList(data.ranking);
                        setTimeout(() => this.hideLoading(), 500);
                    } else throw new Error("API Error");
                } catch (e) {
                    this.showLoading('Load Failed', true);
                    setTimeout(() => this.hideLoading(), 2000);
                } finally { this.list.style.opacity = '1'; }
            }

            async submitScore() {
                if (!RANKING_API_URL) { alert("API URL not set"); return; }
                const name = this.input.value.trim() || "NoName";
                const score = player.score;
                this.lastSubmitName = name;
                this.submitBtn.disabled = true;
                this.submitBtn.innerText = "Sending...";
                this.showLoading('Sending Score...');
                setTimeout(() => {
                    this.form.style.display = 'none';
                    ui.status.innerText = "Score Sent!";
                }, 300);
                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.showLoading('Waiting for Update...');
                    setTimeout(() => this.fetchRanking(), 3000);
                } catch (e) {
                    this.showLoading('Send Failed', true);
                    setTimeout(() => this.hideLoading(), 2000);
                } 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 && player.score == item.score) li.classList.add('highlight');
                    li.innerHTML = `<span class="rank-pos">${index + 1}</span><span class="rank-name" title="${this.escapeHtml(item.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'; this.lastSubmitName = ""; }
            escapeHtml(str) {
                if(!str) return "";
                return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
            }
        }
        const ranking = new RankingManager();

        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; }
                    else { this.sounds[key] = new Audio(url); this.sounds[key].preload = 'auto'; this.sounds[key].volume = 0.6; }
                });
            }
            play(name) { if (this.isMuted) return; const s = this.sounds[name]; if (s) { const c = s.cloneNode(); c.volume = s.volume; c.play().catch(()=>{}); } }
            playBGM() { if (this.isMuted || !this.bgm) return; this.bgm.play().catch(()=>{}); }
            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'), restartBtn: document.getElementById('restart-btn')
        };
        const pieces = 'ILJOTSZ';
        const colors = [null, '#FF0D72', '#0DC2FF', '#0DFF72', '#F538FF', '#FF8E0D', '#FFE138', '#3877FF'];

        function createMatrix(w, h) { const m = []; while (h--) m.push(new Array(w).fill(0)); return m; }
        function createPiece(t) {
            if (t === 'I') return [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]];
            if (t === 'L') return [[0, 2, 0], [0, 2, 0], [0, 2, 2]];
            if (t === 'J') return [[0, 3, 0], [0, 3, 0], [3, 3, 0]];
            if (t === 'O') return [[4, 4], [4, 4]];
            if (t === 'Z') return [[5, 5, 0], [0, 5, 5], [0, 0, 0]];
            if (t === 'S') return [[0, 6, 6], [6, 6, 0], [0, 0, 0]];
            if (t === 'T') return [[0, 7, 0], [7, 7, 7], [0, 0, 0]];
        }
        function drawMatrix(m, o, ctx = context) {
            m.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        ctx.fillStyle = colors[value]; ctx.fillRect(x + o.x, y + o.y, 1, 1);
                        ctx.lineWidth = 0.05; ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.strokeRect(x + o.x, y + o.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 ox = (5 - nextPiece[0].length) / 2; const oy = (5 - nextPiece.length) / 2;
            drawMatrix(nextPiece, {x: ox, y: oy}, nextContext);
        }
        function merge(a, p) { p.matrix.forEach((r, y) => r.forEach((v, x) => { if (v !== 0) a[y + p.pos.y][x + p.pos.x] = v; })); }
        function rotate(m, d) {
            for (let y = 0; y < m.length; ++y) for (let x = 0; x < y; ++x) [m[x][y], m[y][x]] = [m[y][x], m[x][y]];
            if (d > 0) m.forEach(r => r.reverse()); else m.reverse();
        }
        function collide(a, p) {
            const m = p.matrix, o = p.pos;
            for (let y = 0; y < m.length; ++y) for (let x = 0; x < m[y].length; ++x)
                if (m[y][x] !== 0 && (a[y + o.y] && a[y + o.y][x + o.x]) !== 0) return true;
            return false;
        }
        function arenaSweep() {
            let rowCount = 1, cleared = 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; cleared++;
            }
            if (cleared > 0) audio.play(cleared >= 4 ? 'tetris' : 'clear');
        }
        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(o) { player.pos.x += o; if (collide(arena, player)) player.pos.x -= o; else audio.play('move'); }
        function playerRotate(d) {
            const p = player.pos.x; let o = 1; rotate(player.matrix, d);
            while (collide(arena, player)) {
                player.pos.x += o; o = -(o + (o > 0 ? 1 : -1));
                if (o > player.matrix[0].length) { rotate(player.matrix, -d); player.pos.x = p; 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 = `Score: ${player.score}`; ui.overlay.querySelector('h1').innerText = "GAME OVER";
                ui.overlay.style.display = 'flex'; ranking.showForm();
            }
        }
        function updateScore() {
            ui.score.innerText = player.score; ui.lines.innerText = player.lines;
            const l = Math.floor(player.score / 100) + 1; ui.level.innerText = l;
            baseDropInterval = Math.max(100, 1000 - (l - 1) * 100);
        }
        let dropCounter = 0, baseDropInterval = 1000, lastTime = 0, isGameOver = true, isFastDropping = false, fastDropInterval = 40;
        function update(t = 0) {
            if (isGameOver) return;
            const dt = t - lastTime; lastTime = t; dropCounter += dt;
            if (dropCounter > (isFastDropping ? fastDropInterval : baseDropInterval)) 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', e => {
            if (document.activeElement.tagName === 'INPUT') return;
            if ([37, 38, 39, 40].includes(e.keyCode)) e.preventDefault();
            if (isGameOver) return;
            if (e.keyCode === 37) playerMove(-1); else if (e.keyCode === 39) playerMove(1);
            else if (e.keyCode === 40) playerDrop(); else if (e.keyCode === 81) playerRotate(-1);
            else if (e.keyCode === 87 || e.keyCode === 38) playerRotate(1);
        }, {passive: false});
        function setupTouchControls() {
            const bind = (id, type) => {
                const b = document.getElementById(id);
                b.addEventListener('pointerdown', e => { e.preventDefault(); if(isGameOver)return; b.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 end = e => { e.preventDefault(); b.classList.remove('active'); if(type==='down')isFastDropping=false; };
                b.addEventListener('pointerup', end); b.addEventListener('pointerleave', end); b.addEventListener('pointercancel', end);
            };
            bind('btn-left', 'left'); bind('btn-right', 'right'); bind('btn-rotate', 'rotate'); bind('btn-down', 'down');
        }
        setupTouchControls();
        function startGame() {
            arena.forEach(r => r.fill(0)); player.score = 0; player.lines = 0; nextPiece = null; isFastDropping = false;
            updateScore(); playerReset(); isGameOver = false; ui.overlay.style.display = 'none'; ranking.hideForm();
            ui.overlay.querySelector('h1').innerText = "TETRIS"; audio.playBGM(); update();
        }
        ranking.fetchRanking();
        ui.restartBtn.addEventListener('click', startGame);
        context.fillStyle = '#000'; context.fillRect(0, 0, canvas.width, canvas.height);
    </script>
</body>
</html>

セキュリティに関するちょっとしたお話

今回作成したランキングシステムですが、コードを見れば分かる通り、APIのURL(キー)がJavaScriptの中にそのまま書かれています。

「これってセキュリティ的に大丈夫なの?」と心配される方もいるかもしれません。

結論から言うと、**「ブログ用のミニゲームとしては許容範囲」**です。このランキングシステムは個人情報を一切扱わず、ただ「名前」と「数字」を記録するだけなので、情報漏洩のリスクはありません。

もちろん、悪意のあるプログラマーがURLを使って「偽のスコア」を送信することは可能です。しかし、それを防ぐために複雑な認証システムを入れると、「1ファイルでコピペで動く」という手軽さが失われてしまいます。

もし荒らされてしまった場合は、スプレッドシートの行を削除するか、GASのURLを変更すれば済みます。あくまで「読者のみんなで楽しむための機能」として、性善説で運用するのが、この手の個人開発ゲームのコツだと思っています。

これからのあんちゃんブログ

Ver.5の開発を通して、AI(Gemini)は単にコードを書くだけでなく、エラーの原因を突き止め、代替案を提示してくれる「頼れる相棒」であることを再確認しました。

次回は、このランキング機能を使って「期間限定スコアアタック大会」などを開催してみたいですね。もしこのコードを使って自分のブログにテトリスを設置した方がいれば、ぜひ教えてください!見に行きます!

それでは、また!


コメント

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