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 バケット
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 #日常