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レーティング難易度調整・認知科学的な設計根拠まで含めた完全版はこちら: