1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPI + Vanilla JSで画像フラッシュ表示UIを作る ── タイムバー・ライフ制・即時フィードバックの実装

1
Posted at

FastAPI + Vanilla JSで画像フラッシュ表示UIを作る ── タイムバー・ライフ制・即時フィードバックの実装

はじめに

「画像を一瞬だけ見せて、何があったか答えさせる」というUIを作る。

  • 画像を0.1〜5秒だけフラッシュ表示
  • 残り時間をアニメーションするタイムバーで可視化
  • ライフ制でプレッシャーを演出
  • 正解/不正解を即時フィードバック

ポイントはゲームとしての緊張感の設計だ。ただ表示するだけでなく、「見逃したら取り返せない」というプレッシャーが記憶の定着を高める。

デモ動画イメージ:

[準備画面]
"準備ができたら開始を押してください"
        ↓ボタン押下
[フラッシュ表示 1.5秒]
🟩🟩🟩🟩🟩🟩🟩🟩 ← タイムバーが縮んでいく
        ↓終了
[想起入力画面]
"見たものをすべて書いてください"
[テキストエリア] [送信]
        ↓送信
[結果表示]
スコア: 78点 ⭐⭐⭐
正解: リンゴ、テーブル、本
見落とし: 観葉植物

前提条件・環境

pip install fastapi uvicorn python-multipart anthropic

ディレクトリ構造:

flash-trainer/
├── main.py
├── static/
│   ├── index.html
│   ├── style.css
│   └── app.js
├── scenes/          # 訓練用画像(JPEG/PNG)
└── scenes.json      # シーンのメタデータ

実装

1. scenes.json(シーンデータ)

[
  {
    "id": "room_001",
    "filename": "room_01.jpg",
    "objects": ["赤いリンゴ", "木製のテーブル", "青い本", "白いコップ", "観葉植物"],
    "scene_type": "room",
    "difficulty": "easy"
  }
]

2. FastAPIバックエンド(main.py)

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse, HTMLResponse
from pydantic import BaseModel
import json, random, os
from anthropic import Anthropic

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/scenes", StaticFiles(directory="scenes"), name="scenes")

client = Anthropic()

with open("scenes.json") as f:
    SCENES = json.load(f)

@app.get("/", response_class=HTMLResponse)
async def root():
    return open("static/index.html").read()

@app.get("/api/scene")
async def get_scene(object_count: int = 5):
    """難易度に応じたシーンを返す(正解リストは含めない)"""
    candidates = [s for s in SCENES if len(s["objects"]) >= object_count]
    if not candidates:
        return JSONResponse({"error": "No scene found"}, 400)

    scene = random.choice(candidates)
    return {
        "scene_id": scene["id"],
        "image_url": f"/scenes/{scene['filename']}",
        "object_count": object_count,
        # 正解はクライアントに渡さない
    }

class ScoreRequest(BaseModel):
    scene_id: str
    user_recall: str
    exposure_ms: int

@app.post("/api/score")
async def score(req: ScoreRequest):
    """Claude APIで採点する"""
    scene = next((s for s in SCENES if s["id"] == req.scene_id), None)
    if not scene:
        return JSONResponse({"error": "Scene not found"}, 404)

    prompt = f"""採点してください。

正解: {json.dumps(scene["objects"], ensure_ascii=False)}
回答: {req.user_recall}
露出時間: {req.exposure_ms}ms

表記のゆれは正解として扱い、部分的な説明にも加点してください。

JSON形式のみで返してください:
{{
  "score": 整数,
  "correct_items": ["正解したアイテム"],
  "missed_items": ["見落としたアイテム"],
  "feedback": "50文字以内のアドバイス"
}}"""

    msg = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=400,
        messages=[{"role": "user", "content": prompt}]
    )
    raw = msg.content[0].text.strip()
    if "```" in raw:
        raw = raw.split("```")[1].split("```")[0].replace("json", "").strip()
    return json.loads(raw)

3. HTML(static/index.html)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>フラッシュトレーナー</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <div id="app">

    <!-- ステータスバー -->
    <div id="status-bar">
      <div id="timer-wrap"><div id="timer-bar"></div></div>
      <div id="lives-wrap">
        <span class="life" id="life1">❤️</span>
        <span class="life" id="life2">❤️</span>
        <span class="life" id="life3">❤️</span>
      </div>
      <div id="score-display">スコア: <span id="total-score">0</span></div>
    </div>

    <!-- 準備画面 -->
    <div id="ready-screen" class="screen active">
      <p class="hint">画像が一瞬だけ表示されます。<br>見たものをすべて覚えてください。</p>
      <button id="start-btn" onclick="startSession()">開始 ▶</button>
    </div>

    <!-- フラッシュ画像 -->
    <div id="flash-screen" class="screen">
      <img id="scene-img" src="" alt="">
    </div>

    <!-- 想起入力画面 -->
    <div id="recall-screen" class="screen">
      <p>見たものをすべて書いてください</p>
      <textarea id="recall-input"
        placeholder="例: 赤いリンゴ、木製のテーブル、青い本..."
        rows="4"></textarea>
      <button onclick="submitRecall()">送信 →</button>
    </div>

    <!-- 結果画面 -->
    <div id="result-screen" class="screen">
      <div id="score-circle"><span id="result-score">0</span></div>
      <div id="result-rank"></div>
      <div id="correct-list"></div>
      <div id="missed-list"></div>
      <p id="feedback-text"></p>
      <button onclick="nextSession()">次へ →</button>
    </div>

  </div>
  <script src="/static/app.js"></script>
</body>
</html>

4. CSS(static/style.css)

* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: sans-serif; background: #0A1628; color: #E8F4FF;
  min-height: 100vh; display: flex; align-items: center; justify-content: center; }

#app { width: 100%; max-width: 480px; padding: 1rem; }

/* ステータスバー */
#status-bar { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
#timer-wrap { flex: 1; height: 8px; background: #1A3A6A; border-radius: 4px; overflow: hidden; }
#timer-bar { height: 100%; background: #00C8A0; border-radius: 4px;
  transition: width 0.1s linear, background-color 0.3s; }
#score-display { font-size: 0.85rem; color: #4A8ABF; white-space: nowrap; }

/* 画面共通 */
.screen { display: none; text-align: center; }
.screen.active { display: block; }

/* 準備画面 */
#ready-screen .hint { color: #4A8ABF; margin-bottom: 2rem; line-height: 1.8; }

/* フラッシュ画像 */
#flash-screen img { width: 100%; border-radius: 8px; }

/* 想起入力 */
#recall-screen p { margin-bottom: 1rem; color: #4A8ABF; }
#recall-input { width: 100%; padding: 0.75rem; background: #0D1F3A;
  border: 1px solid #1A4A8A; border-radius: 6px; color: #E8F4FF;
  font-size: 1rem; resize: vertical; margin-bottom: 1rem; }

/* ボタン */
button { padding: 0.75rem 2rem; background: #00C8A0; color: #000;
  border: none; border-radius: 6px; font-size: 1rem; cursor: pointer;
  font-weight: 600; }
button:hover { background: #00A080; }

/* 結果 */
#score-circle { width: 100px; height: 100px; border-radius: 50%;
  border: 3px solid #00C8A0; display: flex; align-items: center;
  justify-content: center; margin: 0 auto 1rem; font-size: 2rem; font-weight: 700; }
#result-rank { font-size: 1.2rem; margin-bottom: 1.5rem; color: #00C8A0; }
#correct-list, #missed-list { margin: 0.5rem 0; font-size: 0.9rem; }
#feedback-text { color: #4A8ABF; margin: 1rem 0; font-style: italic; }

5. JavaScript(static/app.js)

let currentScene = null;
let lives = 3;
let totalScore = 0;
let sessionCount = 0;
let exposureMs = 1500;  // 初期露出時間

// 画面切り替え
function showScreen(id) {
  document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
  document.getElementById(id).classList.add('active');
}

// セッション開始
async function startSession() {
  const objectCount = Math.min(5 + Math.floor(sessionCount / 3), 15);
  const res = await fetch(`/api/scene?object_count=${objectCount}`);
  currentScene = await res.json();

  showScreen('flash-screen');
  document.getElementById('scene-img').src = currentScene.image_url;

  // タイムバー開始
  startTimerBar(exposureMs);

  // 露出時間後に隠す
  setTimeout(() => {
    showScreen('recall-screen');
    document.getElementById('recall-input').value = '';
    document.getElementById('recall-input').focus();
  }, exposureMs);
}

// タイムバーアニメーション
function startTimerBar(ms) {
  const bar = document.getElementById('timer-bar');
  bar.style.transition = 'none';
  bar.style.width = '100%';
  bar.style.background = '#00C8A0';

  requestAnimationFrame(() => {
    bar.style.transition = `width ${ms}ms linear, background-color ${ms * 0.5}ms ease`;
    bar.style.width = '0%';
    // 残り25%で色を変える
    setTimeout(() => { bar.style.background = '#FF4444'; }, ms * 0.75);
  });
}

// 想起内容の送信
async function submitRecall() {
  const recall = document.getElementById('recall-input').value.trim();
  if (!recall) return;

  const res = await fetch('/api/score', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      scene_id: currentScene.scene_id,
      user_recall: recall,
      exposure_ms: exposureMs
    })
  });
  const result = await res.json();
  showResult(result);
}

// 結果表示
function showResult(result) {
  sessionCount++;

  // スコア表示
  document.getElementById('result-score').textContent = result.score;

  // 段位
  const rank = getRank(result.score);
  document.getElementById('result-rank').textContent = rank;

  // 正解リスト
  document.getElementById('correct-list').innerHTML =
    result.correct_items.length
      ? '' + result.correct_items.join('')
      : '';

  // 見落とし
  document.getElementById('missed-list').innerHTML =
    result.missed_items.length
      ? '❌ 見落とし: ' + result.missed_items.join('')
      : '';

  // フィードバック
  document.getElementById('feedback-text').textContent = result.feedback || '';

  // ライフ処理
  if (result.score < 50) {
    lives = Math.max(0, lives - 1);
    updateLives();
    // 画面を揺らす
    document.getElementById('app').animate(
      [{ transform: 'translateX(-5px)' }, { transform: 'translateX(5px)' },
       { transform: 'translateX(-5px)' }, { transform: 'translateX(0)' }],
      { duration: 300 }
    );
  }

  // 難易度自動調整(簡易版)
  if (result.score >= 85) {
    exposureMs = Math.max(200, Math.floor(exposureMs * 0.85));
  } else if (result.score < 50) {
    exposureMs = Math.min(5000, Math.floor(exposureMs * 1.2));
  }

  // 総スコア更新
  totalScore += result.score;
  document.getElementById('total-score').textContent =
    Math.round(totalScore / sessionCount);

  showScreen('result-screen');
}

function updateLives() {
  ['life1', 'life2', 'life3'].forEach((id, i) => {
    document.getElementById(id).textContent = i < lives ? '❤️' : '🖤';
  });
}

function getRank(score) {
  if (score >= 95) return '🥷 影';
  if (score >= 80) return '⚔️ 上忍';
  if (score >= 65) return '🗡️ 中忍';
  if (score >= 50) return '🔰 下忍';
  return '📖 見習い';
}

function nextSession() {
  if (lives <= 0) {
    alert('ゲームオーバー!最初からやり直しましょう。');
    lives = 3;
    updateLives();
    totalScore = 0;
    sessionCount = 0;
    exposureMs = 1500;
    document.getElementById('total-score').textContent = '0';
  }
  showScreen('ready-screen');
}

6. 起動

uvicorn main:app --reload --port 8000
# → http://localhost:8000 を開く

まとめ

  • setTimeout でフラッシュ表示を制御し、露出時間後に自動で入力画面に切り替える
  • CSS transition でタイムバーをスムーズにアニメーション、残り25%で色変化
  • ライフ制と画面の揺れで「失敗した感」を演出する
  • スコアに応じて exposureMs を更新することで、簡易難易度調整を実現

さらに深く学ぶ

本記事はZenn書籍の実装章を抜粋したものです。
Claude API採点エンジン・Eloレーティング難易度調整・認知科学的な設計根拠まで含めた完全版はこちら:

📘 忍者の網膜記憶術をAIで再現する ── Legacy DX Vol.1(Zenn・1,000円)

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?