これを翻訳してください: 退屈なCSの勉強を楽しい習慣に変えるゲーミフィケーションプラットフォーム
FundaはCS学習をゲームのように継続できるようにするゲーミフィケーション学習プラットフォームです。ロードマップに基づく学習、さまざまなクイズ形式、リアルタイムバトル、週間ランキング、AI学習補助まで1つの流れでつなぐチームプロジェクトでしたが、私はフロントエンド全般でインタラクション中心のUI実装を主導しました。
プロジェクトの核心価値が「楽しい学習体験」であったため、フロントエンドは単にデータを画面に描く役割ではありませんでした。ユーザーが問題を解きながら何を感じるか、結果を受け取る瞬間どのような報賞感を持つか、そして翌日再び戻ってくる理由があるかがすべてフロントエンドと直結していました。
Fundaの3Dキャラクターはサービスの第一印象であり、学習中の感情表現を担当するインタラクションオブジェクトでした。視線追跡、表情変化、クリック反応が生きていなければキャラクターが「装飾」ではなく「存在感」を持つことができませんでした。
リガフィードボーンはウェブ環境でリアルタイム制御が不可能な構造でした。これをそのまま使用するとアニメーション制御自体が阻まれる状況でした。ボーン構造分析→重み付けマッピング→アニメーション遷移検証→WebGLユニフォーム追跡→最終レンダリング点検の5段階デバッグで、演算量の多いデータをウェブランタイムで処理しようとしたことが構造的原因であることを特定しました。
繰り返し再生されるidle・walkのような静的アニメーションデータはBlenderで事前にベイキングしGLBに含めました。ランタイムにボーンマトリックスを計算する必要がなくなりCPU負担を大きく減らしました。
視線追跡(look-at)、クリック反応、照明、影のようにユーザー入力に即時反応しなければならないインタラクションのみをランタイムで処理するように分離しました。SkeletonUtils.clone()でキャラクターインスタンスを分離し各画面で独立したアニメーション状態を維持しました。
キャラクターは案内・祝福・結果などのさまざまな画面で再利用されるため、インスタンシング技法で同一ジオメトリの重複レンダリングなしでGPU負荷を分散しました。
링크 정보를 불러오는 중...
この構造を基にユーザーがコントローラーでキャラクターのアニメーションと表情(シェイプキー)を直接リアルタイム制御する体験ページも実装しました。キャラクターを単なる装飾ではなくサービスブランディング要素や学習体験インターフェースとして二重役割を果たすようにした結果です。
詳細な回顧記事は링크 정보를 불러오는 중...リンクでご覧いただけます。
Fundaは、客観式、O/X、マッチング、コード分析など多様なクイズタイプを提供します。各タイプは単なるフォーマットの羅列ではなく、異なる学習目標を伝えます。客観式は核心概念の認知、O/Xは誤認の修正、コード分析は実行の流れの推論、マッチングは概念間の関係理解を目指します。
| 客観式 (MCQ) | O/X クイズ |
|---|---|
| 最も標準的な概念チェック方式です | よく混同される誤認(アンチパターン)を正します |
![]() | ![]() |
| マッチング (Matching) | コード分析 |
|---|---|
| 概念と定義、または技術間の関係を結びつけます | 実際のコードスニペットの実行結果およびロジックを推論します |
![]() | ![]() |
その中でマッチングクイズで「何をすべきかわからない」というフィードバックが繰り返されました。ユーザーが関係を頭の中でのみ維持すると認知負荷が増大し、学習の流れがすぐに途切れてしまいました。

両方の選択肢をクリックするとSVGパスを動的に生成して接続状態を即座に視覚化しました。ResizeObserverで画面の大きさが変わっても接続線を再計算し、正解提出後には線の色とスタイルで正解/不正解を即座に判断しました。
ユーザクリック → pairs状態を更新
→ DOM位置読み取り (getBoundingClientRect)
→ SVGパス座標計算
→ 接続線レンダリング改善後、直感性に関するフィードバックが0件に収束し、核心学習フローの離脱要因を除去しました。
学習サービスの結果画面は単なる要約表ではありません。「今回の学習が保存され、有意義な成果が残った」という感覚を短く圧縮して伝える装置です。
状況別に異なる報酬経験を設計しました。
![]() | ![]() | ![]() |
|---|
XP、成功率、所要時間カードが左から順に登場します。Framer Motionで登場タイミングを合わせ、カードが現れる瞬間ごとにdingサウンドを順次再生して報酬感を強化しました。
当日の最初の攻略であればStreak画面に分岐します。大きな数字と火花アイコン、週間チェック状態、翌日の学習誘導メッセージで「連続学習記録を続ける」という文脈を伝えました。
復習モードの最後の問題を終えたときは、一般の結果の代わりに別エフェクト画面に分岐します。チェックバッジ、波動、星パーティクルが短く登場した後、学習画面に戻ります。
結果の瞬間にだけ短く正確に演出を使用したため、散漫になることはありませんでした。
| バトルロビー | 問題解決 |
|---|---|
| 正解確認 | 結果画面 |
|---|---|
リアルタイムバトルは単にスコアを同期する機能ではありませんでした。待機室、招待リンク、開始カウントダウン、リアルタイムスコア、結果公開まで異なる状態がルートとUIに同時に反映される必要があり、誤ったエントリー(招待トークンなし、すでに開始されたルーム)も処理する必要がありました。
ソケットインフラとバトルドメインフローを分離する構造を設計しました。リアルタイムの状態変化がコンポーネント各所に散らばらないように4層に分けました。
バトル関連のグローバル状態は battleStore に分離してソケットイベントとUIレンダリングの結合を減らしました。バトルルールが変更されたり状態遷移が追加されても各階層の責任が明確で修正範囲を予測することができました。
サービスローンチのために6,000件のクイズデータセットが必要でしたが、手動制作では数日以上かかる状況でした。
self-hosted n8n(Docker) + Google シート(単一の真実のソース) + Gemini 基盤の自動化パイプラインを直接設計しました。JSON/JSONL/CSV並列出力とDocker並列実行構造で処理速度を最大化しました。

LLM連続ループ呼び出し時、Gemini レートリミットとスキーマパースエラーが急増しました。バッチ間隔遅延ロジックと中間保存ロジックを構成して安定性を確保しました。
APIキー3つを循環使用し1分あたりの処理量を3倍に増やして処理時間も最適化しました。
結果として6,000件のクイズを30分以内に生成し、数日の手動作業を自動化しました。チームリソースをコア機能開発に集中させることができました。
Fundaは非ログインユーザーも最初に学習を体験し、必要に応じて自然にログインにつなげる構造を選択しました。このフロー全体をコードベースのルーティング上で制御しました。
、、と、を組み合わせて、ログイン状況と権限に応じてアクセス経路を明確に分けました。ガードとローダーをルート定義に明示した理由は、サービスが大きくなるほどアクセスポリシーがUIコードの外でも読める必要があり、メンテナンス可能にするためです。
ブラウザ保存とサーバー一時保存を分けた理由は役割が異なるためです。
ブラウザ保存はユーザーがログインしていない状態でも学習の文脈が切れないようにするための装置でした。最後に見ていた位置やフィールドごとの最後の完了ユニット、ゲストの解答進行状態を残しておくことで、リロードしたり、再度アクセスした際にもすぐに学習を続けることができます。ユーザーが感じる利点は「最初から再び探しに行かなくても良い」という即時性です。
一方、Redisはアカウントがない状態でもステップ完了記録とハート状態をサーバー基準で一時的に保管するための階層でした。このようにすることで、ログインの瞬間にclient_id基準のデータをユーザーアカウントに統合でき、単純なUI状態ではなくサービスルールと接続される値をサーバー側で引き継ぐことができます。ユーザーが感じる利点は「体験中に蓄積した進行がログインによって消えない」という連続性です。
つまり、ブラウザは画面の文脈をつかみ、Redisはアカウント転換直前の進行データをつかむ役割を担いました。スケッチブックには構図やメモを残し、作業室のテーブルには本作業に必要な材料を別に整理しておく方法に似ています。
![]() | ![]() | ![]() |
|---|
SM-2ベースのSRSが復習タイミングを計算するならば、定期学習通知メールはその時点を実際の行動に結びつける装置です。ログイン画面の受信同意→設定画面のトグル→メール本文の受信拒否ページまでひとつのフローとして完成しました。
メールは全体送信ではなく「加入5日経過」、「連続学習(ストリーク)途切れ」、「最近14日以内ログイン」、「最後の送信から48時間経過」条件をクエリでフィルタリングし、ヌッジが必要な瞬間にのみメールが届くようにしました。
大量送信時にGmail SMTPサーバーの1秒あたりの送信制限にかかる問題を防ぐため、バッチループ内に遅延ロジックを追加して1秒あたり5通以下の送信速度をアプリケーションレベルで制御し、固定フォーマットによる機械的な印象を避けるために、ユーザー名(displayName)と多様な本文フレーズをランダムに組み合わせて送信し、オープン率を高めました。
最後にURL操作による悪意ある他人の購読解除を防ぐため、7日間有効の一回限りJWTを発行しリンクに含め、@ThrottleでAPI呼び出しを毎分5回に制限してブルートフォース攻撃を防御しました。

プロフィールページは単なるユーザー情報画面ではなく、クイズ解決記録を学習動機に再翻訳する個人ダッシュボードの役割を果たしました。年間グラフ、直近7日間の学習時間グラフ、直近7日間の分野別問題解決グラフで「どれだけやったか」だけでなく「いつやったか」、「どの分野をやったか」まで一目で分かるようにすることが目標でした。
学習記録は1日単位で見せるべきですが、サーバ基準の日付とユーザーローカルの日付がずれると「昨日解いた問題が今日として記録される」エラーが直ちに現れます。でユーザー時間帯を読み取りヘッダーでサーバに伝達し、サーバが返した日付を、、でグラフ・ツールチップ・軸ラベルに一貫して連携しました。

単なるdivグリッドではなく、月ラベル、曜日ラベル、レベル基準色強度、ホバー/フォーカスツールチップを含む学習ヒートマップとして設計しました。記録がないセルはインタラクションに反応せず、実際の記録があるセルのみボタンのように動作するようにし情報密度は高くしつつインタラクションは過度にならないように維持しました。

学習時間グラフと分野別学習量グラフの両方をSVG上にY軸目盛り、グリッドライン、スムーズな曲線パス、エリアグラデーション、ホバーポイント、ポップオーバーを直接実装しました。チャートライブラリよりも製品トーンに合う細かい制御が可能でした。
Fundaを通じて、インタラクションが視覚的な飾りではなく、ユーザーがサービスを使い続けるための設計の一部であることに気づきました。
3Dキャラクター、マッチングクイズ、リアルタイムバトルのような視覚的な実装も扱いましたが、同時にそれぞれのインタラクションがなぜその方式でなければならなかったのか、どのような問題を解決するためにそう設計したのかを中心に据えました。良いインタラクションは派手なモーションだけで終わるのではなく、ユーザーが次の行動を自然に理解し、再び戻ってくる流れの上にあると考えます。
GuestGuardLoginGuardAdminGuardguestLoaderprotectedLoaderIntl.DateTimeFormat().resolvedOptions().timeZonex-time-zonenormalizeDateKeyformatDateKeyLocalformatDateDisplayNameLocalconst resolveTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
async getProfileStreaks(userId: number): Promise<ProfileStreakDay[]> {
return apiFetch.get(`/profiles/${userId}/streaks`, {
headers: { 'x-time-zone': resolveTimeZone() },
});
}const resolveLevel = (count: number) => {
if (count <= 0) return 0;
if (count <= 10) return 1;
if (count <= 30) return 2;
if (count <= 60) return 3;
return 4;
};
// 記録があるセルのみボタンとして動作
const isInteractive = !isPlaceholder && solvedCount > 0;<span
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
aria-label={
isInteractive
? `${formatDateDisplayNameLocal(dayDate)} ${solvedCount}個`
: undefined
}
/>const createSmoothPath = (points: { x: number; y: number }[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
return points.reduce((acc, point, index, array) => {
if (index === 0) return `M ${point.x} ${point.y}`;
const previous = array[index - 1];
const cp1 = controlPoint(previous, array[index - 2], point, false);
const cp2 = controlPoint(point, previous, array[index + 1], true);
return `${acc} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${point.x} ${point.y}`;
}, '');
};<path d={group.areaPath} fill={`url(#gradient-${index})`} />
<path d={group.linePath} fill="none" stroke={group.color} strokeWidth="2.5" />
<circle
cx={point.x} cy={point.y} r="10" fill="transparent"
role="button" tabIndex={0}
aria-label={`${point.label}: ${point.tooltipFormatter(point.value)}`}
/>