2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

20年メモ帳で開発し続け「AIなんてわからん」と言ってたインターネット老人が、CursorとWebアプリをリリースした話

Last updated at Posted at 2026-01-12

Vanilla JavaScriptで作る投資ゲーム:Cursor × Gemini × Supabaseで実現した個人開発の技術スタック

はじめに

本記事では、個人開発で制作したWebゲーム「株カレ」の技術的な実装について紹介します。Vanilla JavaScript(約7000行)で構築し、Cursorによるリファクタリング、Gemini APIによるLLM統合、Supabase RPC関数によるバックエンド集計など、モダンな開発手法を組み合わせた実装のポイントを解説します。

株カレ:https://kabukare.vercel.app/
匿名記事:https://anond.hatelabo.jp/20260112195152

image.png

技術スタック

  • Frontend: HTML5, CSS3 (Mobile First), Vanilla JavaScript
  • Backend/DB: Supabase (PostgreSQL), RPC Functions
  • AI: Google Gemini API (LLM統合)
  • 開発ツール: Cursor (AI支援コーディング)
  • デプロイ: Vercel

1. Cursorによる大規模コードのリファクタリング

課題:3000行を超えた時点での可読性の低下

開発初期はメモ帳でコーディングしていましたが、script.jsが3000行を超えた時点で以下の問題が発生しました:

  • コメントの統一性がない(★V5.6:などのバージョンタグが散在)
  • 関数の説明が冗長(「〜という処理を行う」など)
  • JSDoc形式が統一されていない
  • デッドコード(コメントアウト)が残存

Cursorを使ったリファクタリング戦略

CursorのAI支援機能を活用し、以下の方針でリファクタリングを実施しました:

1.1 コメントの簡素化

Before:

// ★V5.6: ユーザーのあいづちをカテゴリに分類するという処理を行う
function categorizeReply(replyText) {
    // ...
}

After:

/**
 * プレイヤーのあいづちをカテゴリに分類
 * @param {string} replyText - プレイヤーのあいづちテキスト
 * @returns {string} カテゴリID(PRAISE, NEUTRAL, QUESTION, APPRECIATION, FAREWELL)
 */
function categorizeReply(replyText) {
    // ...
}

1.2 バージョンタグの削除

プロジェクト全体から★V5.6:, ★FIX:, ★NEW:などのバージョンタグを一括削除し、Git履歴に任せる方針に変更しました。

1.3 実行コードの完全保護

リファクタリング時は実行可能なコードは1文字たりとも変更しないという厳格なルールを設定。Cursorに以下のようなプロンプトを指定しました:

# 🚫 STRICT PROHIBITIONS
1. コードの削除・変更は厳禁
2. コメントアウトされた「死にコード」も削除禁止
3. 機能の破壊禁止

リファクタリング結果

  • 行数: 約7000行(コメント整理後もほぼ同数)
  • 可読性: JSDoc統一により大幅向上
  • 保守性: バージョンタグ削除により、コードが唯一の情報源(Single Source of Truth)に

2. Supabase RPC関数によるバックエンド集計

設計思想:フロントエンドは「表示」のみ

ゲーム内のランキング、配当計算、株価更新など、すべての集計・計算処理はSupabaseのRPC関数(PostgreSQL関数)で実装しています。

2.1 RPC関数の活用例

ランキング取得(get_ranking_full

// script.js:5477
const { data, error } = await supabaseClient.rpc('get_ranking_full', {
    p_type: 'SHARES',  // ランキングタイプ
    p_user_id: currentUserID,
    p_ticker: null,
    p_limit: 100
});

配当計算(calculate_dividend

// script.js:3180
const { data: divRes, error } = await supabaseClient.rpc('calculate_dividend', {
    target_user_id: currentUserID
});

2.2 セキュリティ:auth.uid()の活用

RPC関数内でauth.uid()を使用することで、フロントエンドからuser_idを渡さずに認証済みユーザーのみが実行できるようにしています:

-- RPC関数内の例
CREATE OR REPLACE FUNCTION perform_chat_action(p_ticker text)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
    v_user_id uuid := auth.uid();  -- フロントエンドから渡さない
    v_user_ap integer;
BEGIN
    -- APチェックはRPC側で最新値を取得
    SELECT ap INTO v_user_ap FROM profiles WHERE user_id = v_user_id;
    -- ...
END;
$$;

2.3 実装済みRPC関数(17個)

  • フロントエンドから直接呼び出し: 14個
  • バックグラウンドジョブ(Cron): 1個
  • トリガー関数: 2個

すべての集計処理をバックエンドで完結させることで、フロントエンドの負荷を軽減し、データの整合性を担保しています。

3. Gemini APIによるLLM統合

3.1 実装概要

ゲーム内のキャラクターと自由に会話できる機能を、Google Gemini APIで実装しました。

image.png

LLMチャット開始処理

// script.js:2871
async function startLLMChat(ticker) {
    // LLMモードに移行
    userState.context.mode = 'LLM_CHAT';
    userState.context.llmHistory = {
        recentChats: [],
        llmConversation: []
    };
    
    // 直近3回分の雑談を会話履歴に保存
    if (userState.context.recentChats && userState.context.recentChats.length > 0) {
        const sameTickerChats = userState.context.recentChats
            .filter(chat => chat.ticker === ticker)
            .slice(-3);
        userState.context.llmHistory.recentChats = sameTickerChats;
    }
    
    // 既存の会話履歴があれば読み込む(DBから)
    await loadLLMConversationHistory(ticker);
}

LLM API呼び出し

// script.js:5087-5233
if (mode === 'LLM_CHAT') {
    if (userState.llmCount <= 0) {
        game.showSystemNotification('LLM使用回数が不足しています');
        return;
    }
    
    // 会話履歴にユーザーメッセージを追加
    userState.context.llmHistory.llmConversation.push({
        role: 'user',
        text: val
    });
    
    // 3往復分(6メッセージ)を超えたら古いものを削除
    if (userState.context.llmHistory.llmConversation.length > 6) {
        userState.context.llmHistory.llmConversation.shift();
    }
    
    // Supabase Edge Function経由でGemini APIを呼び出し
    const { data, error } = await supabaseClient.functions.invoke('llm-chat', {
        body: {
            ticker: ticker,
            userMessage: val,
            conversationHistory: userState.context.llmHistory.llmConversation,
            recentChats: userState.context.llmHistory.recentChats
        }
    });
    
    // 使用回数を減算(RPC関数経由)
    const { data: decrementResult } = await supabaseClient.rpc('decrement_llm_usage', {
        p_user_id: currentUserID
    });
}

3.2 エラーハンドリング

LLM API呼び出し時のエラーを適切に処理しています:

// 503エラー(Service Unavailable / モデル過負荷)
if (e.status === 503 || e.message?.includes('overloaded')) {
    errorMessage = 'LLMモデルが過負荷状態です。しばらく時間をおいてから再度お試しください。';
}
// 429エラー(レート制限)
else if (e.isRateLimit || e.status === 429) {
    errorMessage = '本日のLLM使用回数の上限に達しました。';
}

3.3 会話履歴の管理

  • メモリ効率: 3往復分(6メッセージ)を上限として、古いメッセージを自動削除
  • 永続化: Supabaseのuser_activity_logsテーブルに保存
  • コンテキスト: 直近3回分の雑談も履歴に含めて、キャラクターの性格を維持

4. Mobile First設計とキーボード対応

4.1 visualViewport APIによるキーボード対応

モバイルデバイスでキーボードが表示された際のUI調整を、visualViewport APIで実装しています:

// script.js:1551-1623
adjustForKeyboard() {
    const viewport = window.visualViewport;
    const windowHeight = window.innerHeight;
    const viewportHeight = viewport.height;
    const keyboardHeight = windowHeight - viewportHeight;
    
    if (keyboardHeight > 150) {
        // キーボードが表示されている
        const inputAreaHeight = inputArea.offsetHeight || 60;
        const quickContainerHeight = quickContainer.offsetHeight || 0;
        
        // 入力エリアをキーボードの上に固定
        inputArea.style.position = 'fixed';
        inputArea.style.bottom = `${keyboardHeight}px`;
        
        // クイックリプライを入力エリアの上に固定
        quickContainer.style.position = 'fixed';
        quickContainer.style.bottom = `${keyboardHeight + inputAreaHeight}px`;
        
        // チャットコンテナの下部に余白を追加
        chatContainer.style.paddingBottom = `${inputAreaHeight + quickContainerHeight + 20}px`;
    } else {
        // キーボードが非表示
        inputArea.style.position = '';
        // ...
    }
}

4.2 タッチ操作の最適化

クイックリプライの横スクロールを、マウスドラッグとタッチ操作の両方に対応:

// script.js:1077-1120
const wheelHandler = (e) => {
    e.preventDefault();
    quickContainer.scrollLeft += e.deltaY;
};

const mouseDownHandler = (e) => {
    isDragging = true;
    startX = e.pageX - quickContainer.offsetLeft;
    scrollLeft = quickContainer.scrollLeft;
};

const mouseMoveHandler = (e) => {
    if (!isDragging) return;
    e.preventDefault();
    const x = e.pageX - quickContainer.offsetLeft;
    const walk = (x - startX) * 2;
    quickContainer.scrollLeft = scrollLeft - walk;
};

5. ゲームバランス調整へのAI活用

ゲーム内の経済バランス(配当率、株価変動率など)の調整に、CursorのAI支援機能を活用しました。

image.png

5.1 課題

初期実装では、特定のキャラクター(ファストフード企業)の配当金が強くなりすぎる問題が発生。数学的な知識が限られているため、手動での調整が困難でした。

5.2 AIによる相談プロセス

  1. 現状の数式を提示: 配当計算ロジックをCursorに説明
  2. 問題点の特定: AIが「保有金額ベースの逓減がLv3以下に適用されていない」と指摘
  3. 修正案の提案: ランクレベルベースの逓減倍率を追加する提案
  4. 実装と検証: 修正を実装し、ゲーム内でテスト

5.3 最終的な配当計算ロジック

-- 基本配当率 × 保有金額ベース逓減(Lv4以上のみ) × ランクレベルベース逓減
-- ランクレベルベース逓減: Lv5以上=0.7倍、Lv4=0.85倍、Lv3以下=1.0倍

このように、AIの力を借りながらも、最終的な判断は開発者が行う形でバランス調整を実施しました。

6. 開発で得られた知見

6.1 Cursorの効果

  • リファクタリング効率: 手動では数日かかる作業が数時間で完了
  • コード品質: JSDoc統一により、可読性が大幅に向上
  • 学習効果: AIの提案を通じて、ベストプラクティスを学べた

6.2 Supabase RPC関数のメリット

  • パフォーマンス: フロントエンドでの全件計算を回避
  • セキュリティ: auth.uid()による認証の一元管理
  • 保守性: ビジネスロジックをバックエンドに集約

6.3 LLM統合の課題と対策

  • コスト管理: 1日3回の無料枠 + 有料プランでの制限を実装
  • レイテンシ: Edge Function経由で呼び出し、タイムアウト処理を実装
  • エラーハンドリング: 503/429/500エラーを適切に処理

7. まとめ

個人開発で約7000行のVanilla JavaScriptアプリケーションを構築し、以下の技術を組み合わせました:

  1. Cursor: 大規模コードのリファクタリング支援
  2. Supabase RPC: バックエンド集計によるパフォーマンス最適化
  3. Gemini API: LLM統合による対話機能の実現
  4. Mobile First: visualViewport APIによるモバイル最適化

特に、CursorによるリファクタリングとSupabase RPC関数によるバックエンド集計は、コードの可読性とパフォーマンスの両面で大きな効果がありました。

個人開発でも、適切なツールと設計思想を組み合わせることで、保守性の高いアプリケーションを構築できることを実感しました。

参考資料


免責事項: 本記事は個人開発の経験に基づく技術的な知見の共有です。プロダクション環境での利用にあたっては、適切なテストとセキュリティ対策を実施してください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?