Oshi Quest 技術解説

公開日: 2026/6/6

著者: すぱろー

OSHI QUEST — アーキテクチャ構成図

graph TB
    subgraph Browser["ブラウザ(クライアント)"]
        UI["Next.js 16 App\\nReact + Tailwind v4 + Framer Motion"]
        Phaser["Phaser.js v4\\nワールド描画エンジン"]
        UI <--> Phaser
    end

    subgraph Vercel["Vercel Hobby(サーバー)"]
        direction TB
        Generate["/api/generate\\nAI生成 + XP加算 + 建物追加"]
        Items["/api/items\\nアイテム一覧"]
        Reports["/api/reports\\nレポート一覧"]
        Comments["/api/comments\\nワールドコメント"]
        User["/api/user\\nユーザー情報"]
        WB["/api/world-buildings\\n建物一覧"]
    end

    subgraph Supabase["Supabase(ap-south-1)"]
        direction TB
        SAuth["Auth\\nGoogle OAuth"]
        DB[("PostgreSQL\\nusers / reports / items\\nworld_buildings / world_comments\\nxp_events")]
        Storage["Storage\\nitem-images bucket\\n(PNG / SVG)"]
        RPC["RPC\\nincrement_user_xp()"]
        SAuth --> DB
        DB <--> RPC
    end

    subgraph AI["AI サービス(全て無料枠)"]
        Gemini["Gemini 2.0 Flash\\nテキスト生成 Primary\\n(Google AI Studio)"]
        Groq["Groq\\nllama-3.3-70b-versatile\\nテキスト生成 Fallback"]
        CF["Cloudflare Workers AI\\nflux-1-schnell\\n画像生成 ~230枚/日"]
        SVG["SVG ジェネレーター\\n(サーバーサイド)\\n画像生成 Fallback"]
    end

    subgraph Google["Google Cloud"]
        OAuth["OAuth 2.0\\nテストモード\\n最大100ユーザー"]
    end

    Browser -->|HTTP / Fetch API| Vercel
    Vercel -->|supabase-js| Supabase
    Generate -->|generateObject| Gemini
    Gemini -->|429 quota超過| Groq
    Generate -->|POST REST API| CF
    CF -->|base64 PNG| Storage
    CF -->|失敗時| SVG
    SVG -->|SVG Buffer| Storage
    SAuth <-->|OAuth flow| OAuth

    style Vercel fill:#000,color:#c2f542,stroke:#c2f542
    style Supabase fill:#1a1a2e,color:#4ade80,stroke:#4ade80
    style AI fill:#1a0a2e,color:#a855f7,stroke:#a855f7
    style Browser fill:#0a1628,color:#60a5fa,stroke:#60a5fa
    style Google fill:#1a0a0a,color:#f97316,stroke:#f97316

サービス一覧

月額コスト: $0

OSHI QUEST — データベース ER 図

erDiagram
    users {
        UUID id PK "Supabase Auth の UUID と一致"
        TEXT name
        INTEGER level "デフォルト 1"
        INTEGER total_xp "デフォルト 0"
        TEXT title "デフォルト 見習いリスナー"
        INTEGER city_level "ワールドの見た目レベル"
        TEXT background_image_url "カスタム背景(未使用)"
        TIMESTAMPTZ created_at
    }

    reports {
        UUID id PK
        UUID user_id FK
        TEXT target_name "視聴した配信・作品名"
        TEXT youtube_url "任意"
        TEXT content "感想本文"
        TEXT streamer_reply "配信者からの返信(任意)"
        INTEGER gained_xp "この感想で獲得したXP"
        TIMESTAMPTZ created_at
    }

    items {
        UUID id PK
        UUID user_id FK
        UUID report_id FK "感想から生成されたアイテム"
        TEXT name "AI生成アイテム名"
        TEXT description "AI生成説明文"
        TEXT rarity "Normal / Rare / Epic / Legendary"
        TEXT image_url "Supabase Storage URL"
        TIMESTAMPTZ created_at
    }

    world_buildings {
        UUID id PK
        UUID user_id FK
        INTEGER zone "ゾーン番号(1〜N)"
        INTEGER slot "ゾーン内スロット(1〜8)"
        TEXT building_key "hut / stage / concert_hall など"
        TEXT stream_name "ホバー表示用の配信名"
        TEXT link_url "クリックで開くURL(任意)"
        TIMESTAMPTZ created_at
    }

    world_comments {
        UUID id PK
        UUID world_owner_id FK "コメント先ワールドのオーナー"
        TEXT visitor_name "任意(デフォルト 名無しさん)"
        TEXT content
        BOOLEAN is_streamer "配信者コメントは強調表示"
        TIMESTAMPTZ created_at
    }

    xp_events {
        UUID id PK
        UUID user_id FK
        INTEGER amount "加算XP量"
        TEXT source "report など"
        UUID reference_id "参照元ID(任意)"
        TIMESTAMPTZ created_at
    }

    users ||--o{ reports : "1人が複数の感想を投稿"
    users ||--o{ items : "1人が複数のアイテムを所有"
    users ||--o{ world_buildings : "1人のワールドに複数の建物"
    users ||--o{ world_comments : "1人のワールドに複数のコメント"
    users ||--o{ xp_events : "1人のXP履歴"
    reports ||--o| items : "1つの感想から1つのアイテムを生成"

テーブル補足

Storage バケット

  • INSERT / UPDATE: 認証済みユーザーのみ
  • SELECT: 全員(公開URL)
  • OSHI QUEST — ページ遷移図

    ルーティング全体図

    graph LR
        subgraph Public["認証不要(公開)"]
            Login["/login\\nGoogleログイン"]
            WorldPublic["/worlds/[id]\\n公開ワールド\\n七尾など訪問者向け"]
        end
    
        subgraph Protected["認証必要(ログイン後)"]
            Home["/\\nホーム\\nワールドプレビュー・XP・アイテム"]
            NewReport["/reports/new\\n感想を記録する\\n→ AI アイテム生成"]
            Reports["/reports\\nレポート履歴"]
            Items["/items\\nアイテム図鑑"]
            WorldMy["/worlds/my\\nマイワールド\\n→ /worlds/[user.id] にリダイレクト"]
        end
    
        subgraph Auth["認証フロー"]
            Callback["/auth/callback\\nOAuth コールバック"]
        end
    
        %% 未ログイン時のリダイレクト
        Home -->|未ログイン| Login
        NewReport -->|未ログイン| Login
        Reports -->|未ログイン| Login
        Items -->|未ログイン| Login
        WorldMy -->|未ログイン| Login
    
        %% ログインフロー
        Login -->|Google OAuth| Callback
        Callback -->|認証成功| Home
    
        %% ナビゲーションメニューからの遷移
        Home -->|ハンバーガーメニュー| NewReport
        Home -->|ハンバーガーメニュー| Reports
        Home -->|ハンバーガーメニュー| Items
        Home -->|ハンバーガーメニュー| WorldMy
    
        %% ページ内リンク
        NewReport -->|← Home| Home
        Reports -->|← Home| Home
        Items -->|← Home| Home
        WorldMy -->|リダイレクト| WorldPublic
        WorldPublic -->|← Back Home| Home
    
        %% ワールド共有
        WorldPublic -->|Share URL ボタン\\n(オーナーのみ)| WorldPublic
    
        style Public fill:#0a1a0a,color:#4ade80,stroke:#4ade80
        style Protected fill:#0a0a1a,color:#60a5fa,stroke:#60a5fa
        style Auth fill:#1a0a0a,color:#f97316,stroke:#f97316

    ページ詳細

    認証不要ページ

    認証必要ページ


    感想フォームの状態遷移

    stateDiagram-v2
        [*] --> 入力中 : ページ読み込み
    
        入力中 --> バリデーションエラー : 必須項目が空で送信
        バリデーションエラー --> 入力中 : 修正
    
        入力中 --> 生成中 : Generate Item ボタン押下\\n(targetName + content が必要)
    
        生成中 --> 生成成功 : AI からレスポンス受信
        生成中 --> フォールバック : API エラー\\n(ハードコードアイテムを表示)
    
        生成成功 --> 結果表示 : アイテム・XP・画像を表示
        フォールバック --> 結果表示 : 固定アイテムを表示
    
        結果表示 --> 入力中 : ← Write Another
        結果表示 --> [*]

    アイテム詳細モーダルの状態

    stateDiagram-v2
        [*] --> 図鑑一覧 : /items を開く
    
        図鑑一覧 --> モーダル表示 : アイテムカードをクリック
        モーダル表示 --> 図鑑一覧 : × ボタン or 外側クリック
    
        state モーダル表示 {
            [*] --> アイテム情報表示
            アイテム情報表示 --> 感想表示 : reports データあり
            感想表示 --> 配信者コメント表示 : streamer_reply あり
        }

    OSHI QUEST — シーケンス図

    1. 感想投稿 → アイテム生成フロー(メインフロー)

    sequenceDiagram
        actor User as ユーザー
        participant Form as /reports/new
        participant API as /api/generate
        participant Gemini as Gemini 2.0 Flash
        participant Groq as Groq llama-3.3-70b
        participant CF as Cloudflare Workers AI
        participant Storage as Supabase Storage
        participant DB as Supabase DB
    
        User->>Form: 視聴作品・感想・配信者コメントを入力
        User->>Form: Generate Item ボタン押下
    
        Form->>API: POST /api/generate\\n{targetName, content, youtubeUrl, streamerReply}
    
        API->>DB: auth.getUser() — 認証確認
        DB-->>API: user
    
        Note over API,Groq: アイテム名・説明・XP・レアリティ生成
        API->>Gemini: generateObject(itemSchema, prompt)
        alt Gemini 成功
            Gemini-->>API: {item, desc, xp, rarity}
        else 429 quota超過
            Gemini-->>API: Error 429
            API->>Groq: generateText(prompt + formatHint)
            Groq-->>API: JSON テキスト
            API->>API: JSON regex パース
        end
    
        API->>DB: INSERT reports\\n{user_id, target_name, content, streamer_reply, gained_xp}
        DB-->>API: report (id)
    
        API->>DB: SELECT users.total_xp (レベルアップ前の値を保存)
        DB-->>API: oldXP
    
        API->>DB: RPC increment_user_xp(user_id, xp)
        DB-->>API: OK
    
        API->>DB: SELECT users.total_xp (XP加算後)
        DB-->>API: newXP
    
        alt レベルアップ発生
            Note over API,Gemini: 称号生成
            API->>Gemini: generateObject(titleSchema, prompt)
            alt Gemini 成功
                Gemini-->>API: {title}
            else quota超過
                API->>Groq: generateText(titlePrompt)
                Groq-->>API: {title}
            end
    
            loop oldLevel+1 〜 newLevel
                API->>DB: SELECT world_buildings (最後の zone/slot を取得)
                DB-->>API: last building
                API->>DB: INSERT world_buildings\\n{user_id, zone, slot, building_key, stream_name, link_url}
            end
    
            API->>DB: UPDATE users\\n{level, city_level, title}
        else レベルアップなし
            API->>DB: UPDATE users\\n{level, city_level}
        end
    
        Note over API,Storage: アイテム画像生成
        API->>CF: POST flux-1-schnell\\n{prompt: "fantasy RPG icon, {rarity}..."}
        alt CF 成功
            CF-->>API: {result: {image: base64}}
            API->>Storage: upload {reportId}.png
            Storage-->>API: publicUrl
        else CF 失敗
            API->>API: buildItemSVG(itemName, rarity)
            API->>Storage: upload {reportId}.svg
            Storage-->>API: publicUrl
        end
    
        API->>DB: INSERT items\\n{user_id, report_id, name, description, rarity, image_url}
    
        API-->>Form: {item, desc, xp, rarity, imageUrl}
        Form->>User: アイテム錬成結果を表示\\n(画像・レアリティ・+XP アニメーション)

    2. Google OAuth ログインフロー

    sequenceDiagram
        actor User as ユーザー
        participant Login as /login
        participant Supabase as Supabase Auth
        participant Google as Google OAuth 2.0
        participant Callback as /auth/callback
        participant DB as Supabase DB
    
        User->>Login: 「Googleでログイン」ボタン押下
        Login->>Supabase: signInWithOAuth({provider: 'google'})
        Supabase-->>Login: Google OAuth URL
        Login->>Google: リダイレクト
        User->>Google: Googleアカウントで認証
        Google-->>Callback: code パラメータ付きリダイレクト
        Callback->>Supabase: exchangeCodeForSession(code)
        Supabase-->>Callback: session (access_token)
    
        alt 初回ログイン
            Supabase->>DB: Trigger: handle_new_user()\\nINSERT users {id, name, title}
        end
    
        Callback-->>User: / へリダイレクト

    3. 公開ワールド閲覧・コメント投稿フロー

    sequenceDiagram
        actor Visitor as 訪問者(七尾など)
        participant World as /worlds/[id]
        participant API_User as /api/user
        participant API_WB as /api/world-buildings
        participant API_CM as /api/comments
        participant DB as Supabase DB
    
        Visitor->>World: ワールドURL を開く(ログイン不要)
    
        par 並列フェッチ
            World->>API_User: GET /api/user?id={worldId}
            API_User->>DB: SELECT users WHERE id={worldId}
            DB-->>API_User: {name, level, title, city_level}
            API_User-->>World: ワールドオーナー情報
    
            World->>API_WB: GET /api/world-buildings?userId={worldId}
            API_WB->>DB: SELECT world_buildings WHERE user_id={worldId}
            DB-->>API_WB: buildings[]
            API_WB-->>World: 建物一覧
        end
    
        World->>World: Phaser.js でワールドを描画\\n(建物・浮遊音符・プレイヤー)
    
        Visitor->>World: 💬 ボタンをクリック
        World->>API_CM: GET /api/comments?worldId={worldId}
        API_CM->>DB: SELECT world_comments WHERE world_owner_id={worldId}
        DB-->>API_CM: comments[]
        API_CM-->>World: コメント一覧表示
    
        Visitor->>World: 名前(任意)・コメントを入力して Send
        World->>World: 楽観的更新(即座に表示)
        World->>API_CM: POST /api/comments\\n{worldId, visitorName, content}
        API_CM->>DB: INSERT world_comments
        DB-->>API_CM: OK
    #日常

    コメント

    コメントを書く