YUGOROU commited on
Commit
fe32c60
·
verified ·
1 Parent(s): bb679e8

UX overhaul: fairness fixes (IME Enter, no-peek answering, think masking, kana judging), answer timer, points scoreboard, live confidence meter, AI-planned-buzz marker, buzz SFX+mute, roundover recap+share+new-match, friendly errors, wait-time tips

Browse files
add_kana.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = [
4
+ # "pykakasi",
5
+ # ]
6
+ # ///
7
+ """問題プールの正解(truth)に読み仮名 truth_kana を一括付与する。
8
+
9
+ 目的(UX 修正 A4): frontend の解答判定はかな⇄カナ統一のみで漢字⇄読みを救済できず、
10
+ 「夏目漱石」が正解の問題に「なつめそうせき」とタイプすると誤判定になる。
11
+ pykakasi(辞書ベース・オフライン)で truth のひらがな読みを生成して JSON に持たせ、
12
+ frontend judge は truth と truth_kana の両方に照合する。
13
+
14
+ 使い方:
15
+ uv run space/add_kana.py # questions_pool_ja.json / questions_ja.json を更新
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import pathlib
21
+
22
+ import pykakasi
23
+
24
+ HERE = pathlib.Path(__file__).resolve().parent
25
+ TARGETS = ["questions_pool_ja.json", "questions_ja.json"]
26
+
27
+ kks = pykakasi.kakasi()
28
+
29
+
30
+ def to_hira(text: str) -> str:
31
+ return "".join(item["hira"] for item in kks.convert(text))
32
+
33
+
34
+ def main() -> None:
35
+ for name in TARGETS:
36
+ path = HERE / name
37
+ if not path.exists():
38
+ print(f"[add_kana] skip (not found): {name}")
39
+ continue
40
+ data = json.loads(path.read_text(encoding="utf-8"))
41
+ qs = data.get("questions", [])
42
+ added = same = 0
43
+ for q in qs:
44
+ truth = q.get("truth", "")
45
+ t = truth[0] if isinstance(truth, list) else truth
46
+ kana = to_hira(t)
47
+ # 読みが元と同じ(既にかな/英数)なら冗長なので持たせない。
48
+ if kana and kana != t:
49
+ q["truth_kana"] = kana
50
+ added += 1
51
+ else:
52
+ q.pop("truth_kana", None)
53
+ same += 1
54
+ path.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")),
55
+ encoding="utf-8")
56
+ print(f"[add_kana] {name}: {len(qs)} questions, kana added {added}, unchanged {same}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
app.py CHANGED
@@ -132,12 +132,18 @@ def _mock_round(qs: list[dict], match: str) -> dict:
132
  out = [{"id": q.get("id"), "category": q.get("category", ""),
133
  "pattern": q.get("pattern", ""), "genre": q.get("genre", ""),
134
  "full": q["full"], "truth": q["truth"],
 
135
  "buzzer": "ai", "buzzFrac": 0.6, "answer": q["truth"], "correct": True,
 
136
  "aiCrossed": True,
137
- "aiThink": [{"frac": 0.3, "text": "(mock)手がかりを解析中…"},
138
- {"frac": 0.55, "text": f"(mock){q['truth']} と予測"}]}
 
 
 
 
139
  for q in qs]
140
- return {"match": match, "questions": out}
141
 
142
 
143
  def build_round_api(genre: str | None = None, theta: float | None = None) -> dict:
@@ -145,6 +151,7 @@ def build_round_api(genre: str | None = None, theta: float | None = None) -> dic
145
  if MOCK:
146
  return _mock_round(qs_sel, match)
147
  qs = [{"id": q.get("id"), "full": q["full"], "truth": q["truth"],
 
148
  "category": q.get("category", ""), "pattern": q.get("pattern", ""),
149
  "genre": q.get("genre", "")}
150
  for q in qs_sel]
 
132
  out = [{"id": q.get("id"), "category": q.get("category", ""),
133
  "pattern": q.get("pattern", ""), "genre": q.get("genre", ""),
134
  "full": q["full"], "truth": q["truth"],
135
+ "truthKana": q.get("truth_kana", ""),
136
  "buzzer": "ai", "buzzFrac": 0.6, "answer": q["truth"], "correct": True,
137
+ "aiFullAnswer": q["truth"], "aiFullCorrect": True,
138
  "aiCrossed": True,
139
+ "confCurve": [{"f": round(0.15 + 0.85 * i / 9, 3), "c": round(0.05 + 0.9 * i / 9, 3)}
140
+ for i in range(10)],
141
+ "aiThink": [{"frac": 0.3, "text": "(mock)手がかりを解析中…",
142
+ "masked": "(mock)手がかりを解析中…"},
143
+ {"frac": 0.55, "text": f"(mock){q['truth']} と予測",
144
+ "masked": "(mock)●● と予測"}]}
145
  for q in qs]
146
+ return {"match": match, "theta": DEFAULT_THETA, "questions": out}
147
 
148
 
149
  def build_round_api(genre: str | None = None, theta: float | None = None) -> dict:
 
151
  if MOCK:
152
  return _mock_round(qs_sel, match)
153
  qs = [{"id": q.get("id"), "full": q["full"], "truth": q["truth"],
154
+ "truth_kana": q.get("truth_kana", ""),
155
  "category": q.get("category", ""), "pattern": q.get("pattern", ""),
156
  "genre": q.get("genre", "")}
157
  for q in qs_sel]
questions_ja.json CHANGED
@@ -1,49 +1 @@
1
- {
2
- "match": "早押しクイズAI デモマッチ",
3
- "lang": "ja",
4
- "note": "デモ用オリジナル問題(競技クイズ構文を再現)。学習コーパス(JAQKET/AI王)は非公開のため不使用。AI の buzz位置・回答・思考・正誤は実モデルで生成する。",
5
- "questions": [
6
- {
7
- "id": 1,
8
- "category": "地理 / パラレル",
9
- "pattern": "「〜ですが」構文",
10
- "full": "日本一面積が広い都道府県は北海道ですが、二番目に面積が広い都道府県はどこでしょう?",
11
- "truth": "岩手県"
12
- },
13
- {
14
- "id": 2,
15
- "category": "文学",
16
- "pattern": "決定的情報での早押し",
17
- "full": "『走れメロス』や『人間失格』などの作品で知られる、昭和を代表する小説家は誰でしょう?",
18
- "truth": "太宰治"
19
- },
20
- {
21
- "id": 3,
22
- "category": "地理 / パラレル",
23
- "pattern": "前フリへのお手つき注意",
24
- "full": "世界で最も流域面積が広い川はアマゾン川ですが、世界で最も長い川は何でしょう?",
25
- "truth": "ナイル川"
26
- },
27
- {
28
- "id": 4,
29
- "category": "化学",
30
- "pattern": "元素記号での確定",
31
- "full": "金属の中で最も電気を通しやすい、元素記号Agで表される金属は何でしょう?",
32
- "truth": "銀"
33
- },
34
- {
35
- "id": 5,
36
- "category": "歴史",
37
- "pattern": "前フリ→決定情報",
38
- "full": "1543年、ポルトガル人によって日本に鉄砲が伝わった、現在は鹿児島県に属する島はどこでしょう?",
39
- "truth": "種子島"
40
- },
41
- {
42
- "id": 6,
43
- "category": "スポーツ",
44
- "pattern": "言い換えの絞り込み",
45
- "full": "四大大会のうち、唯一クレーコートで行われ、フランス語で「全仏オープン」とも呼ばれるテニスの大会は何でしょう?",
46
- "truth": "ローラン・ギャロス"
47
- }
48
- ]
49
- }
 
1
+ {"match":"早押しクイズAI デモマッチ","lang":"ja","note":"デモ用オリジナル問題(競技クイズ構文を再現)。学習コーパス(JAQKET/AI王)は非公開のため不使用。AI の buzz位置・回答・思考・正誤は実モデルで生成する。","questions":[{"id":1,"category":"地理 / パラレル","pattern":"「〜ですが」構文","full":"日本一面積が広い都道府県は北海道ですが、二番目に面積が広い都道府県はどこでしょう?","truth":"岩手県","truth_kana":"いわてけん"},{"id":2,"category":"文学","pattern":"決定的情報での早押し","full":"『走れメロス』や『人間失格』などの作品で知られる、昭和を代表する小説家は誰でしょう?","truth":"太宰治","truth_kana":"だざいおさむ"},{"id":3,"category":"地理 / パラレル","pattern":"前フリへのお手つき注意","full":"世界で最も流域面積が広い川はアマゾン川ですが、世界で最も長い川は何でしょう?","truth":"ナイル川","truth_kana":"ないるかわ"},{"id":4,"category":"化学","pattern":"元素記号での確定","full":"金属の中で最も電気を通しやすい、元素記号Agで表される金属は何でしょう?","truth":"銀","truth_kana":"ぎん"},{"id":5,"category":"歴史","pattern":"前フリ→決定情報","full":"1543年、ポルトガル人によって日本に鉄砲が伝わった、現在は鹿児島県に属する島はどこでしょう?","truth":"種子島","truth_kana":"しゅししま"},{"id":6,"category":"スポーツ","pattern":"言い換えの絞り込み","full":"四大大会のうち、唯一クレーコートで行われ、フランス語で「全仏オープン」とも呼ばれるテニスの大会は何でしょう?","truth":"ローラン・ギャロス","truth_kana":"ろーらん・ぎゃろす"}]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
questions_pool_ja.json CHANGED
The diff for this file is too large to render. See raw diff
 
round_builder.py CHANGED
@@ -216,8 +216,9 @@ def _buzz_confs(m: Models, texts, batch_size, max_seq_length):
216
 
217
 
218
  def _find_buzz_pos(m: Models, question: str, theta: float, stride: int,
219
- batch_size: int, max_seq_length: int) -> tuple[int, bool]:
220
- """char-stream を stride 走査し conf≥θ の初出位置を返す(無交差なら全長・False)。"""
 
221
  L = len(question)
222
  start = max(10, int(0.15 * L))
223
  positions = list(range(start, L + 1, max(1, stride)))
@@ -226,10 +227,11 @@ def _find_buzz_pos(m: Models, question: str, theta: float, stride: int,
226
  confs = _buzz_confs(
227
  m, [BUZZ_USER_TEMPLATE.format(n=p, prefix=question[:p]) for p in positions],
228
  batch_size, max_seq_length)
 
229
  for p, c in zip(positions, confs):
230
  if c >= theta:
231
- return p, True
232
- return L, False
233
 
234
 
235
  def _main_generate(m: Models, prefix: str, max_new_tokens: int, think: bool):
@@ -299,8 +301,13 @@ def _main_generate_batch(m: Models, prefixes: list[str], max_new_tokens: int, th
299
  _SENT_SPLIT = re.compile(r"(?<=[。!?\.!?])\s*")
300
 
301
 
302
- def _think_steps(think_txt: str, buzz_frac: float, max_steps: int = 3) -> list[dict]:
303
- """<think> を文分割し frac ~0.2〜buzz_frac に均等配置(最大 max_steps 文)。"""
 
 
 
 
 
304
  if not think_txt:
305
  return []
306
  sents = [s.strip() for s in _SENT_SPLIT.split(think_txt) if s.strip()]
@@ -309,12 +316,19 @@ def _think_steps(think_txt: str, buzz_frac: float, max_steps: int = 3) -> list[d
309
  # 長すぎる場合は末尾優先で max_steps 文に圧縮(決定的な手がかりは後半に出る)。
310
  if len(sents) > max_steps:
311
  sents = sents[-max_steps:]
 
 
 
 
 
 
 
312
  n = len(sents)
313
  lo, hi = 0.18, max(0.22, buzz_frac - 0.02)
314
  steps = []
315
  for i, s in enumerate(sents):
316
  frac = lo + (hi - lo) * (i / max(1, n - 1)) if n > 1 else hi
317
- steps.append({"frac": round(frac, 3), "text": s})
318
  return steps
319
 
320
 
@@ -344,9 +358,9 @@ def build_round(questions: list[dict], m: Models, *, qutils,
344
  L = len(full)
345
  if progress:
346
  progress(i, len(questions), q.get("id"))
347
- buzz_pos, crossed = _find_buzz_pos(m, full, theta, stride, buzz_batch, max_seq_length)
348
  metas.append({"q": q, "full": full, "L": L, "buzz_pos": buzz_pos,
349
- "crossed": crossed, "prefix": full[:buzz_pos],
350
  "golds": q["truth"] if isinstance(q["truth"], list) else [q["truth"]]})
351
 
352
  # ── pass 2: gemma 生成(buzz地点回答 + rebound 用の全文回答)をまとめてバッチ ──
@@ -366,6 +380,8 @@ def build_round(questions: list[dict], m: Models, *, qutils,
366
  L = mm["L"]
367
  buzz_frac = round(min(0.99, mm["buzz_pos"] / L), 4)
368
  correct = qutils.is_correct(answer, mm["golds"], loose=True)
 
 
369
  rec = {
370
  "id": mm["q"].get("id", i + 1),
371
  "category": mm["q"].get("category", ""),
@@ -373,11 +389,13 @@ def build_round(questions: list[dict], m: Models, *, qutils,
373
  "genre": mm["q"].get("genre", ""),
374
  "full": mm["full"],
375
  "truth": mm["golds"][0],
 
376
  "buzzer": "ai", # AI は buzz_frac で押す。human は live で先押し可(engine 側)。
377
  "buzzFrac": buzz_frac,
378
  "answer": answer,
379
  "correct": bool(correct),
380
- "aiThink": _think_steps(think_txt, buzz_frac),
 
381
  "aiCrossed": mm["crossed"], # θ 未交差(自信不足で全文まで行った)かの内部フラグ
382
  }
383
  if rebound and full_gens[i] is not None:
@@ -391,4 +409,4 @@ def build_round(questions: list[dict], m: Models, *, qutils,
391
  for rec in out_qs:
392
  rec["audio"] = _synth_audio(m.tts, rec["full"], tts_ref, tts_steps)
393
 
394
- return {"match": match, "questions": out_qs}
 
216
 
217
 
218
  def _find_buzz_pos(m: Models, question: str, theta: float, stride: int,
219
+ batch_size: int, max_seq_length: int) -> tuple[int, bool, list[dict]]:
220
+ """char-stream を stride 走査し conf≥θ の初出位置を返す(無交差なら全長・False)。
221
+ 第3返り値は実測の確信度カーブ [{f: 位置/全長, c: conf}](frontend のライブメーター用)。"""
222
  L = len(question)
223
  start = max(10, int(0.15 * L))
224
  positions = list(range(start, L + 1, max(1, stride)))
 
227
  confs = _buzz_confs(
228
  m, [BUZZ_USER_TEMPLATE.format(n=p, prefix=question[:p]) for p in positions],
229
  batch_size, max_seq_length)
230
+ curve = [{"f": round(p / L, 3), "c": round(c, 3)} for p, c in zip(positions, confs)]
231
  for p, c in zip(positions, confs):
232
  if c >= theta:
233
+ return p, True, curve
234
+ return L, False, curve
235
 
236
 
237
  def _main_generate(m: Models, prefix: str, max_new_tokens: int, think: bool):
 
301
  _SENT_SPLIT = re.compile(r"(?<=[。!?\.!?])\s*")
302
 
303
 
304
+ def _think_steps(think_txt: str, buzz_frac: float, max_steps: int = 3,
305
+ mask: tuple = ()) -> list[dict]:
306
+ """<think> を文分割し frac を ~0.2〜buzz_frac に均等配置(最大 max_steps 文)。
307
+
308
+ mask: 解答候補の文字列群。読み上げ中に think を表示すると人間が答えを読めて
309
+ しまう(カンニング)ため、各文の mask 文字列を ●● に置換した "masked" も併載する。
310
+ frontend は判定前 masked / 判定後 text を表示する。"""
311
  if not think_txt:
312
  return []
313
  sents = [s.strip() for s in _SENT_SPLIT.split(think_txt) if s.strip()]
 
316
  # 長すぎる場合は末尾優先で max_steps 文に圧縮(決定的な手がかりは後半に出る)。
317
  if len(sents) > max_steps:
318
  sents = sents[-max_steps:]
319
+
320
+ def _masked(s: str) -> str:
321
+ for t in mask:
322
+ if t and len(t) >= 2:
323
+ s = s.replace(t, "●●")
324
+ return s
325
+
326
  n = len(sents)
327
  lo, hi = 0.18, max(0.22, buzz_frac - 0.02)
328
  steps = []
329
  for i, s in enumerate(sents):
330
  frac = lo + (hi - lo) * (i / max(1, n - 1)) if n > 1 else hi
331
+ steps.append({"frac": round(frac, 3), "text": s, "masked": _masked(s)})
332
  return steps
333
 
334
 
 
358
  L = len(full)
359
  if progress:
360
  progress(i, len(questions), q.get("id"))
361
+ buzz_pos, crossed, curve = _find_buzz_pos(m, full, theta, stride, buzz_batch, max_seq_length)
362
  metas.append({"q": q, "full": full, "L": L, "buzz_pos": buzz_pos,
363
+ "crossed": crossed, "prefix": full[:buzz_pos], "curve": curve,
364
  "golds": q["truth"] if isinstance(q["truth"], list) else [q["truth"]]})
365
 
366
  # ── pass 2: gemma 生成(buzz地点回答 + rebound 用の全文回答)をまとめてバッチ ──
 
380
  L = mm["L"]
381
  buzz_frac = round(min(0.99, mm["buzz_pos"] / L), 4)
382
  correct = qutils.is_correct(answer, mm["golds"], loose=True)
383
+ # think マスク対象=AIの解答と正解(reading 中の表示で人間が答えを読めないように)。
384
+ mask_strs = tuple({s for s in (answer, mm["golds"][0]) if s})
385
  rec = {
386
  "id": mm["q"].get("id", i + 1),
387
  "category": mm["q"].get("category", ""),
 
389
  "genre": mm["q"].get("genre", ""),
390
  "full": mm["full"],
391
  "truth": mm["golds"][0],
392
+ "truthKana": mm["q"].get("truth_kana", ""), # かな解答の判定救済(frontend judge 用)
393
  "buzzer": "ai", # AI は buzz_frac で押す。human は live で先押し可(engine 側)。
394
  "buzzFrac": buzz_frac,
395
  "answer": answer,
396
  "correct": bool(correct),
397
+ "aiThink": _think_steps(think_txt, buzz_frac, mask=mask_strs),
398
+ "confCurve": mm["curve"], # buzz 回帰ヘッドの実測確信度カーブ(ライブメーター用)
399
  "aiCrossed": mm["crossed"], # θ 未交差(自信不足で全文まで行った)かの内部フラグ
400
  }
401
  if rebound and full_gens[i] is not None:
 
409
  for rec in out_qs:
410
  rec["audio"] = _synth_audio(m.tts, rec["full"], tts_ref, tts_steps)
411
 
412
+ return {"match": match, "theta": theta, "questions": out_qs}
static/bigscreen-engine-en.jsx CHANGED
@@ -16,10 +16,15 @@ function normalizeAns(s) {
16
  t = t.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60)); // かな→カナ
17
  return t.replace(/\s+/g, "").trim(); // 日本語は語間空白が無いので空白は全除去
18
  }
19
- function judgeAnswer(input, truth) {
20
- const a = normalizeAns(input), b = normalizeAns(truth);
21
- if (!a || !b) return false;
22
- return a === b || a.includes(b) || b.includes(a);
 
 
 
 
 
23
  }
24
 
25
  function BigScreenApp() {
@@ -60,6 +65,14 @@ function BigScreenApp() {
60
  const [reboundTo, setReboundTo] = useState(null); // "human" | "ai" | null
61
  const [aiRebound, setAiRebound] = useState(null); // {answer, correct} AIが全文で答え返す時
62
  const [reveal, setReveal] = useState(null); // {truth} 両者誤答 → 正答を大きく表示
 
 
 
 
 
 
 
 
63
 
64
  const timers = useRef([]);
65
  const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
@@ -111,12 +124,13 @@ function BigScreenApp() {
111
  setReboundTo(null);
112
  setAiRebound(null);
113
  setReveal(null);
114
- // 最初の問題から始めるときはスコアもリセット(リプレイをクリーンに)
115
  if (idx === 0) {
116
  setScores({
117
  ai: { pts: 0, correct: 0, wrong: 0, buzzSum: 0, buzzN: 0 },
118
  human: { pts: 0, correct: 0, wrong: 0, buzzSum: 0, buzzN: 0 },
119
  });
 
120
  }
121
 
122
  // タイムライン本体。charMs(1文字あたりミリ秒)は音声長 or READ_CPS から決める。
@@ -130,13 +144,21 @@ function BigScreenApp() {
130
  const tc = Math.round(t.frac * len);
131
  at(tc * charMs + 60, () => setThinkShown((prev) => [...prev, t]));
132
  });
133
- // confidence ランプ(buzzまで上昇
134
- const confSteps = 24;
135
- for (let s = 1; s <= confSteps; s++) {
136
- const ms = (buzzChar * charMs) * (s / confSteps);
137
- const target = qq.buzzer === "ai" ? 0.04 + (0.95 - 0.04) * (s / confSteps)
138
- : 0.04 + (0.62 - 0.04) * (s / confSteps);
139
- at(ms, () => setConfidence(target));
 
 
 
 
 
 
 
 
140
  }
141
  // Buzz in(音声もここで停止=読み上げが buzz 位置で止まる)
142
  at(buzzChar * charMs + 80, () => {
@@ -146,12 +168,14 @@ function BigScreenApp() {
146
  setPhase("buzzed");
147
  setBuzzCompare((bc) => ({ ...bc, [qq.buzzer]: qq.buzzFrac }));
148
  setFlash({ on: true, side: qq.buzzer, key: idx + "-" + Date.now() });
 
149
  });
150
  at(buzzChar * charMs + 80 + 1100, () => setFlash(null));
151
 
152
  if (qq.buzzer === "human") {
153
  liveBuzzFrac.current = qq.buzzFrac;
154
- at(buzzChar * charMs + 80 + 1200, () => { setPhase("answering"); setRevealRest(true); });
 
155
  return;
156
  }
157
  // AI buzzed — auto-resolve (scripted)
@@ -159,8 +183,10 @@ function BigScreenApp() {
159
  at(buzzChar * charMs + 80 + 2600, () => {
160
  setPhase("judged");
161
  const reward = 1.0 + 0.5 * (1 - qq.buzzFrac);
162
- setLastResult({ correct: qq.correct, pts: reward });
 
163
  if (window.playResult) window.playResult(qq.correct); // AI判定の正誤音
 
164
  setScores((prev) => {
165
  const next = JSON.parse(JSON.stringify(prev));
166
  const me = next.ai;
@@ -184,6 +210,9 @@ function BigScreenApp() {
184
  if (qq.audio) {
185
  const audio = new Audio(qq.audio);
186
  audio.preload = "auto";
 
 
 
187
  audioRef.current = audio;
188
  let started = false;
189
  const startWith = (charMs) => { if (started) return; started = true; buildTimeline(charMs); };
@@ -232,18 +261,22 @@ function BigScreenApp() {
232
  setBuzzCompare((bc) => ({ ...bc, human: liveBuzzFrac.current }));
233
  setPhase("buzzed");
234
  setFlash({ on: true, side: "human", key: idx + "-h-" + Date.now() });
 
235
  at(1100, () => setFlash(null));
236
- at(1200, () => { setPhase("answering"); setRevealRest(true); });
 
237
  }, [round]);
238
 
239
- // AI が全文で答え返す(人間が誤答した後のリバウンド)。precompute 済 aiFullAnswer を使う。
240
  const aiReboundResolve = useCallback((idx, qq) => {
241
  const fa = qq.aiFullAnswer || qq.answer;
242
  const fc = !!qq.aiFullCorrect;
243
  setReboundTo("ai");
244
  setAiRebound({ answer: fa, correct: fc });
 
245
  at(2000, () => {
246
  if (window.playResult) window.playResult(fc); // AIリバウンド解答の正誤音
 
247
  setScores((prev) => {
248
  const next = JSON.parse(JSON.stringify(prev));
249
  if (fc) { next.ai.pts += 1.0; next.ai.correct += 1; }
@@ -259,14 +292,16 @@ function BigScreenApp() {
259
  if (phaseRef.current !== "answering") return;
260
  const idx = qIndexRef.current;
261
  const qq = round.questions[idx];
262
- const correct = judgeAnswer(text, qq.truth);
263
  setHumanAnswer(text);
264
  setPhase("judged");
 
265
 
266
  if (reboundToRef.current === "human") {
267
  // AI 誤答後のリバウンド解答:正解はフラット +1.0、不正解はそれ以上減点しない。
268
- setLastResult({ correct, pts: 1.0 });
269
  if (window.playResult) window.playResult(correct); // 人間リバウンド解答の正誤音
 
270
  setScores((prev) => {
271
  const next = JSON.parse(JSON.stringify(prev));
272
  if (correct) { next.human.pts += 1.0; next.human.correct += 1; }
@@ -279,8 +314,9 @@ function BigScreenApp() {
279
 
280
  // 通常(人間が早押しした本解答)
281
  const reward = 1.0 + 0.5 * (1 - liveBuzzFrac.current);
282
- setLastResult({ correct, pts: reward });
283
  if (window.playResult) window.playResult(correct); // 人間の早押し解答の正誤音
 
284
  setScores((prev) => {
285
  const next = JSON.parse(JSON.stringify(prev));
286
  const me = next.human;
@@ -297,21 +333,44 @@ function BigScreenApp() {
297
  }
298
  }, [round, autoplay, total, aiReboundResolve]);
299
 
300
- // Human passes (no answer).
301
  const passHuman = useCallback(() => {
302
  if (phaseRef.current !== "answering") return;
303
  const idx = qIndexRef.current;
304
  const qq = round.questions[idx];
305
  clearTimers();
 
306
  if (reboundToRef.current === "human") {
307
  // AI 誤答後に人間もパス → 両者解答なし → 正答を表示して次へ
308
- setPhase("judged");
309
  setReveal({ truth: qq.truth });
310
  if (autoplay) at(3800, () => advance(idx));
311
  return;
312
  }
313
- advance(idx);
314
- }, [round, autoplay, total]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
  // Expose handlers + Space-to-buzz.
317
  useEffect(() => {
@@ -335,55 +394,107 @@ function BigScreenApp() {
335
  ? (sideUsed === "ai" ? T("ai") : T("human")) + T("buzzAt") + Math.round((fracUsed || 0) * 100) + "%"
336
  : "";
337
  const buzzer = (phase === "buzzed" || phase === "answer" || phase === "answering" || phase === "judged") ? activeBuzzer : null;
 
 
 
338
 
339
  return (
340
  <div className="stage-root" data-dir={dir} data-fx={fx}>
341
- <TopBar qIndex={qIndex} total={total} phase={phase} />
342
  <div className="mid">
343
  <AIColumn q={q} thinkShown={thinkShown}
344
  phase={phase} buzzer={buzzer} result={buzzer === "ai" ? lastResult : null}
345
- showReasoning={showReasoning} />
346
  <CenterBoard scores={scores} />
347
  <HumanColumn q={q} phase={phase} buzzer={buzzer}
348
  result={buzzer === "human" ? lastResult : null}
349
- humanAnswer={humanAnswer}
350
  onBuzz={humanBuzz} onAnswer={submitHumanAnswer} onPass={passHuman}
351
  followUp={phase === "judged" && q.followUp ? q.followUp : null} />
352
  </div>
353
  <div className="qband">
354
  <QuestionText q={q} seen={seen} phase={phase} buzzer={buzzer}
355
- accentLabel={accentLabel} revealRest={revealRest} />
356
  </div>
357
  <FlashLayer flash={flash} fx={fx} />
358
  <ReboundBanner reboundTo={reboundTo} aiRebound={aiRebound} reveal={reveal}
359
  aiAnswer={q && q.answer} />
360
  {reveal && <RevealOverlay truth={reveal.truth} />}
361
- {phase === "roundover" && <RoundOver scores={scores} onReplay={() => runQuestion(0)} />}
 
 
 
 
362
  </div>
363
  );
364
  }
365
 
366
- function RoundOver({ scores, onReplay }) {
367
- const win = scores.ai.correct === scores.human.correct ? "draw" : (scores.ai.correct > scores.human.correct ? "ai" : "human");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  return (
369
- <div style={{ position: "absolute", inset: 0, zIndex: 50, display: "grid", placeItems: "center",
370
- background: "color-mix(in oklab, var(--bg) 78%, transparent)", backdropFilter: "blur(6px)" }}>
371
- <div style={{ textAlign: "center", background: "var(--panel)", border: "1px solid var(--line)",
372
- borderRadius: 22, padding: "48px 64px", boxShadow: "0 30px 80px rgba(40,38,30,.16)" }}>
373
- <div style={{ fontFamily: "var(--mono)", fontSize: 13, letterSpacing: ".1em", textTransform: "uppercase",
374
- color: "var(--ink2)", marginBottom: 18 }}>{T("result")}</div>
375
- <div style={{ fontSize: 40, fontWeight: 800, marginBottom: 8,
376
  color: win === "ai" ? "var(--ai)" : win === "human" ? "var(--hu)" : "var(--ink)" }}>
377
  {win === "draw" ? T("draw") : win === "ai" ? T("aiWins") : T("humanWins")}
378
  </div>
379
- <div style={{ fontSize: 56, fontWeight: 800, fontVariantNumeric: "tabular-nums", marginBottom: 24 }}>
380
- <span style={{ color: "var(--ai)" }}>{scores.ai.correct}</span>
381
- <span style={{ color: "var(--ink3)", margin: "0 16px" }}>—</span>
382
- <span style={{ color: "var(--hu)" }}>{scores.human.correct}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  </div>
384
- <button onClick={onReplay} style={{ font: "600 15px var(--ui-font)", padding: "12px 28px",
385
- borderRadius: 999, border: "1px solid var(--line)", background: "var(--ink)", color: "var(--bg)",
386
- cursor: "pointer" }}>{T("replay")}</button>
387
  </div>
388
  </div>
389
  );
 
16
  t = t.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60)); // かな→カナ
17
  return t.replace(/\s+/g, "").trim(); // 日本語は語間空白が無いので空白は全除去
18
  }
19
+ // kana: 正解の読み仮名(truth_kana・pool 生成時に pykakasi で付与)。
20
+ // 漢字の正解に「なつめそうせき」等のかな解答を救済するため、truth と両方に照合する。
21
+ function judgeAnswer(input, truth, kana) {
22
+ const a = normalizeAns(input);
23
+ if (!a) return false;
24
+ return [truth, kana].filter(Boolean).some((t) => {
25
+ const b = normalizeAns(t);
26
+ return b && (a === b || a.includes(b) || b.includes(a));
27
+ });
28
  }
29
 
30
  function BigScreenApp() {
 
65
  const [reboundTo, setReboundTo] = useState(null); // "human" | "ai" | null
66
  const [aiRebound, setAiRebound] = useState(null); // {answer, correct} AIが全文で答え返す時
67
  const [reveal, setReveal] = useState(null); // {truth} 両者誤答 → 正答を大きく表示
68
+ // 解答制限時間(buzz 後に無制限に考えられると AI に不公平・テンポも死ぬ)
69
+ const ANSWER_SEC = 12;
70
+ const [answerLeft, setAnswerLeft] = useState(null);
71
+ // 振り返り(roundover の全問サマリー用・問題 idx → 両者の解答記録)
72
+ const outcomesRef = useRef({});
73
+ const recordOutcome = (idx, patch) => {
74
+ outcomesRef.current[idx] = Object.assign({}, outcomesRef.current[idx], patch);
75
+ };
76
 
77
  const timers = useRef([]);
78
  const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
 
124
  setReboundTo(null);
125
  setAiRebound(null);
126
  setReveal(null);
127
+ // 最初の問題から始めるときはスコアと振り返りもリセット(リプレイをクリーンに)
128
  if (idx === 0) {
129
  setScores({
130
  ai: { pts: 0, correct: 0, wrong: 0, buzzSum: 0, buzzN: 0 },
131
  human: { pts: 0, correct: 0, wrong: 0, buzzSum: 0, buzzN: 0 },
132
  });
133
+ outcomesRef.current = {};
134
  }
135
 
136
  // タイムライン本体。charMs(1文字あたりミリ秒)は音声長 or READ_CPS から決める。
 
144
  const tc = Math.round(t.frac * len);
145
  at(tc * charMs + 60, () => setThinkShown((prev) => [...prev, t]));
146
  });
147
+ // confidence ランプ。実測カーブ(buzz回帰ヘッドの confCurveがあれば本物を再生、
148
+ // 無ければ線形フォールバック(mock 等)。
149
+ if (qq.confCurve && qq.confCurve.length) {
150
+ qq.confCurve.forEach((pt) => {
151
+ const tc = Math.round(pt.f * len);
152
+ if (tc <= buzzChar) at(tc * charMs, () => setConfidence(pt.c));
153
+ });
154
+ } else {
155
+ const confSteps = 24;
156
+ for (let s = 1; s <= confSteps; s++) {
157
+ const ms = (buzzChar * charMs) * (s / confSteps);
158
+ const target = qq.buzzer === "ai" ? 0.04 + (0.95 - 0.04) * (s / confSteps)
159
+ : 0.04 + (0.62 - 0.04) * (s / confSteps);
160
+ at(ms, () => setConfidence(target));
161
+ }
162
  }
163
  // Buzz in(音声もここで停止=読み上げが buzz 位置で止まる)
164
  at(buzzChar * charMs + 80, () => {
 
168
  setPhase("buzzed");
169
  setBuzzCompare((bc) => ({ ...bc, [qq.buzzer]: qq.buzzFrac }));
170
  setFlash({ on: true, side: qq.buzzer, key: idx + "-" + Date.now() });
171
+ if (window.playBuzz) window.playBuzz(qq.buzzer);
172
  });
173
  at(buzzChar * charMs + 80 + 1100, () => setFlash(null));
174
 
175
  if (qq.buzzer === "human") {
176
  liveBuzzFrac.current = qq.buzzFrac;
177
+ // 解答が確定するまで問題文の続きは見せない(カンニング防止)。
178
+ at(buzzChar * charMs + 80 + 1200, () => setPhase("answering"));
179
  return;
180
  }
181
  // AI buzzed — auto-resolve (scripted)
 
183
  at(buzzChar * charMs + 80 + 2600, () => {
184
  setPhase("judged");
185
  const reward = 1.0 + 0.5 * (1 - qq.buzzFrac);
186
+ setLastResult({ correct: qq.correct, pts: reward,
187
+ delta: qq.correct ? reward : -1.5 });
188
  if (window.playResult) window.playResult(qq.correct); // AI判定の正誤音
189
+ recordOutcome(idx, { aiAns: qq.answer, aiOk: qq.correct, aiFrac: qq.buzzFrac });
190
  setScores((prev) => {
191
  const next = JSON.parse(JSON.stringify(prev));
192
  const me = next.ai;
 
210
  if (qq.audio) {
211
  const audio = new Audio(qq.audio);
212
  audio.preload = "auto";
213
+ audio.muted = !!window.__muteAll; // nav のミュートトグルに追従
214
+ audio.playbackRate = speed; // タイムラインは at() が /speed するので音声側も同期
215
+ window.__curAudio = audio;
216
  audioRef.current = audio;
217
  let started = false;
218
  const startWith = (charMs) => { if (started) return; started = true; buildTimeline(charMs); };
 
261
  setBuzzCompare((bc) => ({ ...bc, human: liveBuzzFrac.current }));
262
  setPhase("buzzed");
263
  setFlash({ on: true, side: "human", key: idx + "-h-" + Date.now() });
264
+ if (window.playBuzz) window.playBuzz("human");
265
  at(1100, () => setFlash(null));
266
+ // 解答確定まで問題文の続きは隠す(早押しの意味を守る・カンニング防止)。
267
+ at(600, () => setPhase("answering"));
268
  }, [round]);
269
 
270
+ // AI が全文で答え返す(人間が誤答/パスした後のリバウンド)。precompute 済 aiFullAnswer を使う。
271
  const aiReboundResolve = useCallback((idx, qq) => {
272
  const fa = qq.aiFullAnswer || qq.answer;
273
  const fc = !!qq.aiFullCorrect;
274
  setReboundTo("ai");
275
  setAiRebound({ answer: fa, correct: fc });
276
+ setRevealRest(true); // AIは全文で答えるので、観客にも全文を見せる
277
  at(2000, () => {
278
  if (window.playResult) window.playResult(fc); // AIリバウンド解答の正誤音
279
+ recordOutcome(idx, { aiAns: fa, aiOk: fc, aiRebound: true });
280
  setScores((prev) => {
281
  const next = JSON.parse(JSON.stringify(prev));
282
  if (fc) { next.ai.pts += 1.0; next.ai.correct += 1; }
 
292
  if (phaseRef.current !== "answering") return;
293
  const idx = qIndexRef.current;
294
  const qq = round.questions[idx];
295
+ const correct = judgeAnswer(text, qq.truth, qq.truthKana);
296
  setHumanAnswer(text);
297
  setPhase("judged");
298
+ setRevealRest(true); // 解答が確定したのでここで全文を開示
299
 
300
  if (reboundToRef.current === "human") {
301
  // AI 誤答後のリバウンド解答:正解はフラット +1.0、不正解はそれ以上減点しない。
302
+ setLastResult({ correct, pts: 1.0, delta: correct ? 1.0 : 0 });
303
  if (window.playResult) window.playResult(correct); // 人間リバウンド解答の正誤音
304
+ recordOutcome(idx, { humanAns: text, humanOk: correct, humanRebound: true });
305
  setScores((prev) => {
306
  const next = JSON.parse(JSON.stringify(prev));
307
  if (correct) { next.human.pts += 1.0; next.human.correct += 1; }
 
314
 
315
  // 通常(人間が早押しした本解答)
316
  const reward = 1.0 + 0.5 * (1 - liveBuzzFrac.current);
317
+ setLastResult({ correct, pts: reward, delta: correct ? reward : -1.5 });
318
  if (window.playResult) window.playResult(correct); // 人間の早押し解答の正誤音
319
+ recordOutcome(idx, { humanAns: text, humanOk: correct, humanFrac: liveBuzzFrac.current });
320
  setScores((prev) => {
321
  const next = JSON.parse(JSON.stringify(prev));
322
  const me = next.human;
 
333
  }
334
  }, [round, autoplay, total, aiReboundResolve]);
335
 
336
+ // Human passes (no answer / time up).
337
  const passHuman = useCallback(() => {
338
  if (phaseRef.current !== "answering") return;
339
  const idx = qIndexRef.current;
340
  const qq = round.questions[idx];
341
  clearTimers();
342
+ setPhase("judged");
343
  if (reboundToRef.current === "human") {
344
  // AI 誤答後に人間もパス → 両者解答なし → 正答を表示して次へ
345
+ setRevealRest(true);
346
  setReveal({ truth: qq.truth });
347
  if (autoplay) at(3800, () => advance(idx));
348
  return;
349
  }
350
+ // 人間が早押し後にパス → 誤答と同じく AI に解答権が移る(ルール一貫・正答も必ず見える)。
351
+ recordOutcome(idx, { humanAns: "", humanOk: false, humanFrac: liveBuzzFrac.current });
352
+ aiReboundResolve(idx, qq);
353
+ }, [round, autoplay, total, aiReboundResolve]);
354
+
355
+ // 解答制限時間: answering になったらカウントダウン、0 でパス扱い。
356
+ const passRef = useRef(null);
357
+ useEffect(() => { passRef.current = passHuman; }, [passHuman]);
358
+ useEffect(() => {
359
+ if (phase !== "answering") { setAnswerLeft(null); return; }
360
+ setAnswerLeft(ANSWER_SEC);
361
+ const iv = setInterval(() => {
362
+ setAnswerLeft((p) => {
363
+ if (p == null) return p;
364
+ if (p <= 1) {
365
+ clearInterval(iv);
366
+ if (passRef.current) passRef.current();
367
+ return 0;
368
+ }
369
+ return p - 1;
370
+ });
371
+ }, 1000);
372
+ return () => clearInterval(iv);
373
+ }, [phase, qIndex]);
374
 
375
  // Expose handlers + Space-to-buzz.
376
  useEffect(() => {
 
394
  ? (sideUsed === "ai" ? T("ai") : T("human")) + T("buzzAt") + Math.round((fracUsed || 0) * 100) + "%"
395
  : "";
396
  const buzzer = (phase === "buzzed" || phase === "answer" || phase === "answering" || phase === "judged") ? activeBuzzer : null;
397
+ const theta = round.theta || THRESHOLD_BASE;
398
+ // 人間が AI より先に押した問題は、判定後に「AI はここで押す予定だった」を問題文中に示す。
399
+ const aiPlanFrac = (activeBuzzer === "human" && phase === "judged" && q) ? q.buzzFrac : null;
400
 
401
  return (
402
  <div className="stage-root" data-dir={dir} data-fx={fx}>
403
+ <TopBar qIndex={qIndex} total={total} phase={phase} genre={q && q.genre} />
404
  <div className="mid">
405
  <AIColumn q={q} thinkShown={thinkShown}
406
  phase={phase} buzzer={buzzer} result={buzzer === "ai" ? lastResult : null}
407
+ showReasoning={showReasoning} confidence={confidence} theta={theta} />
408
  <CenterBoard scores={scores} />
409
  <HumanColumn q={q} phase={phase} buzzer={buzzer}
410
  result={buzzer === "human" ? lastResult : null}
411
+ humanAnswer={humanAnswer} timeLeft={answerLeft}
412
  onBuzz={humanBuzz} onAnswer={submitHumanAnswer} onPass={passHuman}
413
  followUp={phase === "judged" && q.followUp ? q.followUp : null} />
414
  </div>
415
  <div className="qband">
416
  <QuestionText q={q} seen={seen} phase={phase} buzzer={buzzer}
417
+ accentLabel={accentLabel} revealRest={revealRest} aiPlan={aiPlanFrac} />
418
  </div>
419
  <FlashLayer flash={flash} fx={fx} />
420
  <ReboundBanner reboundTo={reboundTo} aiRebound={aiRebound} reveal={reveal}
421
  aiAnswer={q && q.answer} />
422
  {reveal && <RevealOverlay truth={reveal.truth} />}
423
+ {phase === "roundover" && (
424
+ <RoundOver scores={scores} round={round} outcomes={outcomesRef.current}
425
+ onReplay={() => runQuestion(0)}
426
+ onNew={() => { if (window.__newMatch) window.__newMatch(); }} />
427
+ )}
428
  </div>
429
  );
430
  }
431
 
432
+ function RoundOver({ scores, round, outcomes, onReplay, onNew }) {
433
+ // 勝敗はポイント(早押しボーナス・誤答ペナルティ込み)で決める。正解数は併記。
434
+ const d = scores.ai.pts - scores.human.pts;
435
+ const win = Math.abs(d) < 0.001 ? "draw" : (d > 0 ? "ai" : "human");
436
+ const fmt = (v) => (Math.round(v * 10) / 10).toFixed(1);
437
+
438
+ const share = () => {
439
+ const text = T("share.text")
440
+ .replace("{h}", scores.human.correct).replace("{a}", scores.ai.correct)
441
+ .replace("{hp}", fmt(scores.human.pts)).replace("{ap}", fmt(scores.ai.pts));
442
+ const url = "https://huggingface.co/spaces/build-small-hackathon/quiz-buzzer-ai";
443
+ window.open("https://twitter.com/intent/tweet?text=" + encodeURIComponent(text)
444
+ + "&url=" + encodeURIComponent(url), "_blank", "noopener");
445
+ };
446
+
447
+ const mark = (ok) => (ok ? <span className="rc-ok">●</span> : <span className="rc-ng">✕</span>);
448
+ const qs = (round && round.questions) || [];
449
+
450
  return (
451
+ <div className="roundover-veil">
452
+ <div className="roundover-card">
453
+ <div className="ro-label">{T("result")}</div>
454
+ <div className="ro-winner" style={{
 
 
 
455
  color: win === "ai" ? "var(--ai)" : win === "human" ? "var(--hu)" : "var(--ink)" }}>
456
  {win === "draw" ? T("draw") : win === "ai" ? T("aiWins") : T("humanWins")}
457
  </div>
458
+ <div className="ro-pts">
459
+ <span style={{ color: "var(--ai)" }}>{fmt(scores.ai.pts)}<small>{T("pts")}</small></span>
460
+ <span className="ro-dash">—</span>
461
+ <span style={{ color: "var(--hu)" }}>{fmt(scores.human.pts)}<small>{T("pts")}</small></span>
462
+ </div>
463
+ <div className="ro-sub">
464
+ {T("correctN")}: <b style={{ color: "var(--ai)" }}>{scores.ai.correct}</b>
465
+ <span className="ro-dim"> vs </span>
466
+ <b style={{ color: "var(--hu)" }}>{scores.human.correct}</b>
467
+ </div>
468
+ {qs.length > 0 && (
469
+ <div className="recap">
470
+ <div className="recap-head">{T("recap")}</div>
471
+ <table className="recap-table">
472
+ <thead>
473
+ <tr><th></th><th className="rc-q">{T("colQ")}</th><th>{T("colTruth")}</th>
474
+ <th>{T("colAI")}</th><th>{T("colYou")}</th></tr>
475
+ </thead>
476
+ <tbody>
477
+ {qs.map((qq, i) => {
478
+ const o = (outcomes && outcomes[i]) || {};
479
+ return (
480
+ <tr key={i}>
481
+ <td className="rc-no">{i + 1}</td>
482
+ <td className="rc-q">{qq.full.length > 26 ? qq.full.slice(0, 26) + "…" : qq.full}</td>
483
+ <td className="rc-truth">{qq.truth}</td>
484
+ <td>{o.aiAns != null ? <span>{o.aiAns || "—"} {mark(o.aiOk)}</span> : <span className="ro-dim">—</span>}</td>
485
+ <td>{o.humanAns != null ? <span>{o.humanAns || T("pass")} {mark(o.humanOk)}</span> : <span className="ro-dim">—</span>}</td>
486
+ </tr>
487
+ );
488
+ })}
489
+ </tbody>
490
+ </table>
491
+ </div>
492
+ )}
493
+ <div className="ro-actions">
494
+ <button className="ro-btn primary" onClick={onNew}>{T("boot.newMatch")}</button>
495
+ <button className="ro-btn" onClick={onReplay}>{T("watchReplay")}</button>
496
+ <button className="ro-btn share" onClick={share}>𝕏 {T("share")}</button>
497
  </div>
 
 
 
498
  </div>
499
  </div>
500
  );
static/bigscreen-style-en.js CHANGED
@@ -211,4 +211,75 @@ window.BIGSCREEN_CSS = `
211
  .ai-submit:hover{filter:brightness(1.06);}
212
  .ai-submit:disabled{opacity:.4;cursor:not-allowed;}
213
  .ai-err{font-size:13px;color:var(--ng);font-weight:600;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  `;
 
211
  .ai-submit:hover{filter:brightness(1.06);}
212
  .ai-submit:disabled{opacity:.4;cursor:not-allowed;}
213
  .ai-err{font-size:13px;color:var(--ng);font-weight:600;}
214
+
215
+ /* ── 解答制限時間(AnswerInput 内) ─────────────────── */
216
+ .ai-timer{float:right;font-family:var(--mono);font-size:14px;font-weight:600;color:var(--ink2);
217
+ font-variant-numeric:tabular-nums;}
218
+ .ai-timer.urgent{color:var(--ng);animation:tblink .6s step-end infinite;}
219
+ @keyframes tblink{50%{opacity:.35;}}
220
+
221
+ /* ── TopBar ジャンル表示 ───────────────────────────── */
222
+ .genre-tag{font:600 14px "Noto Sans JP",var(--ui-font);color:var(--ink2);
223
+ background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:5px 14px;}
224
+
225
+ /* ── AI確信度メーター(buzz回帰ヘッドの実測カーブ) ── */
226
+ .confwrap{display:flex;flex-direction:column;gap:7px;}
227
+ .confbar{position:relative;height:10px;border-radius:999px;background:var(--panel2);
228
+ border:1px solid var(--line);overflow:visible;}
229
+ .cb-fill{position:absolute;left:0;top:0;bottom:0;border-radius:999px;
230
+ background:linear-gradient(90deg,color-mix(in oklab,var(--ai) 55%,var(--panel)),var(--ai));
231
+ transition:width .35s ease;}
232
+ .cb-th{position:absolute;top:-4px;bottom:-4px;width:2.5px;background:var(--ink);
233
+ border-radius:2px;opacity:.6;}
234
+ .cb-lab{font-family:var(--mono);font-size:13px;color:var(--ink2);letter-spacing:.02em;
235
+ display:flex;align-items:baseline;gap:8px;}
236
+ .cb-lab b{color:var(--ai);font-variant-numeric:tabular-nums;font-size:15px;}
237
+ .cb-theta{margin-left:auto;color:var(--ink3);}
238
+
239
+ /* ── 中央スコア=ポイント主表示 ───────────────────── */
240
+ .score-sub{display:block;font-family:var(--mono);font-size:16px;font-weight:500;
241
+ color:var(--ink2);margin-top:10px;letter-spacing:.02em;}
242
+ .score-unit{font-family:var(--mono);font-size:13px;color:var(--ink3);margin-top:16px;
243
+ letter-spacing:.1em;}
244
+
245
+ /* ── AI押下予定マーカー(人間が先押しした問題の判定後) ── */
246
+ .qtext .buzzmark.plan::before{background:transparent;border-left:4px dashed var(--ai);
247
+ width:0;box-shadow:none;opacity:.75;}
248
+ .qtext .buzzmark.plan::after{background:var(--panel);color:var(--ai);
249
+ border:1.5px dashed var(--ai);top:auto;bottom:-40px;}
250
+
251
+ /* ── ROUND OVER(ポイント勝敗+振り返り+シェア) ──── */
252
+ .roundover-veil{position:absolute;inset:0;z-index:50;display:grid;place-items:center;
253
+ background:color-mix(in oklab,var(--bg) 78%,transparent);backdrop-filter:blur(6px);}
254
+ .roundover-card{text-align:center;background:var(--panel);border:1px solid var(--line);
255
+ border-radius:22px;padding:40px 56px 34px;box-shadow:0 30px 80px rgba(40,38,30,.16);
256
+ max-width:1240px;max-height:920px;overflow:auto;}
257
+ .ro-label{font-family:var(--mono);font-size:13px;letter-spacing:.1em;text-transform:uppercase;
258
+ color:var(--ink2);margin-bottom:14px;}
259
+ .ro-winner{font-size:40px;font-weight:800;margin-bottom:6px;}
260
+ .ro-pts{font-size:56px;font-weight:800;font-variant-numeric:tabular-nums;margin-bottom:6px;}
261
+ .ro-pts small{font-size:20px;font-weight:700;margin-left:4px;color:var(--ink3);}
262
+ .ro-dash{color:var(--ink3);margin:0 16px;}
263
+ .ro-sub{font-size:17px;color:var(--ink2);margin-bottom:20px;}
264
+ .ro-sub b{font-size:20px;}
265
+ .ro-dim{color:var(--ink3);}
266
+ .recap{margin:0 auto 22px;text-align:left;}
267
+ .recap-head{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
268
+ color:var(--ink2);margin-bottom:10px;text-align:center;}
269
+ .recap-table{width:100%;border-collapse:collapse;font-size:15px;line-height:1.45;}
270
+ .recap-table th{font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--ink3);
271
+ text-align:left;font-weight:500;padding:4px 12px;border-bottom:1px solid var(--line);}
272
+ .recap-table td{padding:8px 12px;border-bottom:1px solid var(--line2);vertical-align:top;}
273
+ .recap-table .rc-no{font-family:var(--mono);color:var(--ink3);}
274
+ .recap-table .rc-q{max-width:430px;color:var(--ink2);font-family:"Noto Sans JP",var(--ui-font);}
275
+ .recap-table .rc-truth{font-weight:700;}
276
+ .rc-ok{color:var(--ok);font-weight:800;}
277
+ .rc-ng{color:var(--ng);font-weight:800;}
278
+ .ro-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;}
279
+ .ro-btn{font:600 15px var(--ui-font);padding:12px 26px;border-radius:999px;cursor:pointer;
280
+ border:1px solid var(--line);background:var(--panel);color:var(--ink);transition:.18s;}
281
+ .ro-btn:hover{border-color:var(--ink3);}
282
+ .ro-btn.primary{background:var(--ink);color:var(--bg);border-color:var(--ink);}
283
+ .ro-btn.primary:hover{filter:brightness(1.15);}
284
+ .ro-btn.share{border-color:color-mix(in oklab,var(--ai) 45%,var(--line));color:var(--ai);}
285
  `;
static/bigscreen-ui-en.jsx CHANGED
@@ -2,12 +2,16 @@
2
  // Presentational only. State arrives from the engine via props.
3
 
4
  // Split the question text into seen / buzz-marker / unseen.
5
- function QuestionText({ q, seen, phase, buzzer, accentLabel, revealRest }) {
 
6
  const full = q.full;
7
  const seenStr = full.slice(0, seen);
8
  const restStr = full.slice(seen);
9
  const reading = phase === "reading";
10
  const buzzed = buzzer && (phase === "buzzed" || phase === "answer" || phase === "judged");
 
 
 
11
  return (
12
  <div className="qtext">
13
  <span className="seen">{seenStr}</span>
@@ -15,12 +19,21 @@ function QuestionText({ q, seen, phase, buzzer, accentLabel, revealRest }) {
15
  <span className="buzzmark" data-label={accentLabel}></span>
16
  )}
17
  {reading && <span className="caret"></span>}
18
- <span className={revealRest ? "tail" : "unseen"}>{restStr}</span>
 
 
 
 
 
 
 
 
 
19
  </div>
20
  );
21
  }
22
 
23
- function TopBar({ qIndex, total, phase }) {
24
  const phaseLabel = T("phase." + phase) || phase;
25
  return (
26
  <header className="topbar">
@@ -28,6 +41,7 @@ function TopBar({ qIndex, total, phase }) {
28
  <span className="dot"></span>
29
  Quiz Buzzer AI
30
  </div>
 
31
  <div className="spacer"></div>
32
  <span className="qno">{T("q")} <b>{Math.min(qIndex + 1, total)}</b> / {total} {T("qof")}</span>
33
  <span className="phase-tag" data-p={phase === "buzzed" ? "buzz" : ""}>{phaseLabel}</span>
@@ -50,9 +64,14 @@ function AnswerCard({ side, answer, result, show }) {
50
  );
51
  }
52
 
53
- function AIColumn({ q, thinkShown, phase, buzzer, result, showReasoning }) {
54
  const aiAnswered = buzzer === "ai" && (phase === "answer" || phase === "judged");
55
  const live = phase === "reading";
 
 
 
 
 
56
  return (
57
  <section className="col ai" style={{ "--accent": "var(--ai)" }}>
58
  <div className="side-head">
@@ -64,13 +83,22 @@ function AIColumn({ q, thinkShown, phase, buzzer, result, showReasoning }) {
64
  {live ? T("reasoningDots") : aiAnswered ? T("answered") : phase === "buzzed" && buzzer === "ai" ? T("buzzedIn") : T("standby")}
65
  </div>
66
 
 
 
 
 
 
 
 
 
 
67
  <div className="col-mid">
68
  {showReasoning && (
69
  <div className="reason">
70
  <div className="rh">{T("reasoning")}</div>
71
  <ul className="think-list">
72
  {thinkShown.map((t, i) => (
73
- <li key={i} className={i === thinkShown.length - 1 ? "cur" : ""}>{t.text}</li>
74
  ))}
75
  </ul>
76
  </div>
@@ -81,7 +109,7 @@ function AIColumn({ q, thinkShown, phase, buzzer, result, showReasoning }) {
81
  );
82
  }
83
 
84
- function AnswerInput({ onSubmit, onPass }) {
85
  const [val, setVal] = React.useState("");
86
  const [listening, setListening] = React.useState(false);
87
  const [err, setErr] = React.useState("");
@@ -124,11 +152,23 @@ function AnswerInput({ onSubmit, onPass }) {
124
 
125
  return (
126
  <div className="answer-input">
127
- <div className="ai-label">{T("yourAnswer")}</div>
 
 
 
 
 
 
 
128
  <div className="ai-row">
129
  <input ref={inputRef} className="ai-field" type="text" value={val}
130
  onChange={(e) => setVal(e.target.value)}
131
- onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } }}
 
 
 
 
 
132
  placeholder={T("typePlaceholder")} autoComplete="off" spellCheck="false" />
133
  <button type="button" className={"ai-mic" + (listening ? " on" : "")}
134
  onClick={toggleMic} title="Voice input" aria-label="Voice input">
@@ -153,12 +193,13 @@ function AnswerInput({ onSubmit, onPass }) {
153
  );
154
  }
155
 
156
- function HumanColumn({ q, phase, buzzer, result, followUp, humanAnswer, onBuzz, onAnswer, onPass }) {
157
  const live = phase === "reading";
158
  const answering = phase === "answering" && buzzer === "human";
159
  const humanAnswered = (buzzer === "human" && (phase === "answer" || phase === "judged"))
160
  || (phase === "judged" && followUp);
161
- const ans = buzzer === "human" ? (humanAnswer || q.answer) : (followUp ? followUp.answer : null);
 
162
  const res = buzzer === "human" ? result : (followUp ? { correct: followUp.correct } : null);
163
  return (
164
  <section className="col human" style={{ "--accent": "var(--hu)" }}>
@@ -174,7 +215,7 @@ function HumanColumn({ q, phase, buzzer, result, followUp, humanAnswer, onBuzz,
174
 
175
  <div className="col-mid human-body">
176
  {answering ? (
177
- <AnswerInput onSubmit={onAnswer} onPass={onPass} />
178
  ) : !humanAnswered ? (
179
  <React.Fragment>
180
  <div className={"buzzer-ready" + (live ? " armed" : "") + ((phase === "buzzed" && buzzer === "human") ? " hit" : "")}
@@ -205,13 +246,21 @@ function HumanColumn({ q, phase, buzzer, result, followUp, humanAnswer, onBuzz,
205
  }
206
 
207
  function CenterBoard({ scores }) {
 
 
 
208
  return (
209
  <aside className="center">
210
  <div className="score-grid">
211
- <div className="score-num ai">{scores.ai.correct}<small>{T("ai")}</small></div>
 
 
212
  <div className="vs">{T("vs")}</div>
213
- <div className="score-num hu">{scores.human.correct}<small>{T("human")}</small></div>
 
 
214
  </div>
 
215
  </aside>
216
  );
217
  }
 
2
  // Presentational only. State arrives from the engine via props.
3
 
4
  // Split the question text into seen / buzz-marker / unseen.
5
+ // aiPlan: 人間が先押しした問題の判定後に「AIはここで押す予定だった」位置(frac)を示す。
6
+ function QuestionText({ q, seen, phase, buzzer, accentLabel, revealRest, aiPlan }) {
7
  const full = q.full;
8
  const seenStr = full.slice(0, seen);
9
  const restStr = full.slice(seen);
10
  const reading = phase === "reading";
11
  const buzzed = buzzer && (phase === "buzzed" || phase === "answer" || phase === "judged");
12
+ const aiChar = aiPlan ? Math.round(aiPlan * full.length) : 0;
13
+ const showPlan = !!(aiPlan && revealRest && aiChar > seen && aiChar < full.length);
14
+ const tailCls = revealRest ? "tail" : "unseen";
15
  return (
16
  <div className="qtext">
17
  <span className="seen">{seenStr}</span>
 
19
  <span className="buzzmark" data-label={accentLabel}></span>
20
  )}
21
  {reading && <span className="caret"></span>}
22
+ {showPlan ? (
23
+ <span className={tailCls}>
24
+ {full.slice(seen, aiChar)}
25
+ <span className="buzzmark plan"
26
+ data-label={T("aiPlanned") + Math.round(aiPlan * 100) + "%"}></span>
27
+ {full.slice(aiChar)}
28
+ </span>
29
+ ) : (
30
+ <span className={tailCls}>{restStr}</span>
31
+ )}
32
  </div>
33
  );
34
  }
35
 
36
+ function TopBar({ qIndex, total, phase, genre }) {
37
  const phaseLabel = T("phase." + phase) || phase;
38
  return (
39
  <header className="topbar">
 
41
  <span className="dot"></span>
42
  Quiz Buzzer AI
43
  </div>
44
+ {genre ? <span className="genre-tag">{genre}</span> : null}
45
  <div className="spacer"></div>
46
  <span className="qno">{T("q")} <b>{Math.min(qIndex + 1, total)}</b> / {total} {T("qof")}</span>
47
  <span className="phase-tag" data-p={phase === "buzzed" ? "buzz" : ""}>{phaseLabel}</span>
 
64
  );
65
  }
66
 
67
+ function AIColumn({ q, thinkShown, phase, buzzer, result, showReasoning, confidence, theta }) {
68
  const aiAnswered = buzzer === "ai" && (phase === "answer" || phase === "judged");
69
  const live = phase === "reading";
70
+ // 判定が出るまで think は masked(答えを ●● 化)で表示する。読めばカンニングできてしまうため。
71
+ const revealed = phase === "judged" || phase === "roundover";
72
+ const thinkText = (t) => (revealed ? t.text : (t.masked != null ? t.masked : t.text));
73
+ const confPct = Math.max(0, Math.min(100, Math.round((confidence || 0) * 100)));
74
+ const thetaPct = Math.max(0, Math.min(100, Math.round((theta || 0) * 100)));
75
  return (
76
  <section className="col ai" style={{ "--accent": "var(--ai)" }}>
77
  <div className="side-head">
 
83
  {live ? T("reasoningDots") : aiAnswered ? T("answered") : phase === "buzzed" && buzzer === "ai" ? T("buzzedIn") : T("standby")}
84
  </div>
85
 
86
+ {/* buzz回帰ヘッドの実測確信度(confCurve 再生)。θ に迫る=AIが押す予兆。 */}
87
+ <div className="confwrap">
88
+ <div className="confbar">
89
+ <div className="cb-fill" style={{ width: confPct + "%" }}></div>
90
+ <div className="cb-th" style={{ left: thetaPct + "%" }}></div>
91
+ </div>
92
+ <div className="cb-lab">{T("confLabel")} <b>{confPct}%</b><span className="cb-theta">θ {thetaPct}%</span></div>
93
+ </div>
94
+
95
  <div className="col-mid">
96
  {showReasoning && (
97
  <div className="reason">
98
  <div className="rh">{T("reasoning")}</div>
99
  <ul className="think-list">
100
  {thinkShown.map((t, i) => (
101
+ <li key={i} className={i === thinkShown.length - 1 ? "cur" : ""}>{thinkText(t)}</li>
102
  ))}
103
  </ul>
104
  </div>
 
109
  );
110
  }
111
 
112
+ function AnswerInput({ onSubmit, onPass, timeLeft }) {
113
  const [val, setVal] = React.useState("");
114
  const [listening, setListening] = React.useState(false);
115
  const [err, setErr] = React.useState("");
 
152
 
153
  return (
154
  <div className="answer-input">
155
+ <div className="ai-label">
156
+ {T("yourAnswer")}
157
+ {timeLeft != null && (
158
+ <span className={"ai-timer" + (timeLeft <= 3 ? " urgent" : "")}>
159
+ {T("timeLeft")} {timeLeft}s
160
+ </span>
161
+ )}
162
+ </div>
163
  <div className="ai-row">
164
  <input ref={inputRef} className="ai-field" type="text" value={val}
165
  onChange={(e) => setVal(e.target.value)}
166
+ onKeyDown={(e) => {
167
+ // IME 変換確定の Enter で送信しない(isComposing / keyCode 229 を除外)。
168
+ if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) {
169
+ e.preventDefault(); submit();
170
+ }
171
+ }}
172
  placeholder={T("typePlaceholder")} autoComplete="off" spellCheck="false" />
173
  <button type="button" className={"ai-mic" + (listening ? " on" : "")}
174
  onClick={toggleMic} title="Voice input" aria-label="Voice input">
 
193
  );
194
  }
195
 
196
+ function HumanColumn({ q, phase, buzzer, result, followUp, humanAnswer, timeLeft, onBuzz, onAnswer, onPass }) {
197
  const live = phase === "reading";
198
  const answering = phase === "answering" && buzzer === "human";
199
  const humanAnswered = (buzzer === "human" && (phase === "answer" || phase === "judged"))
200
  || (phase === "judged" && followUp);
201
+ // humanAnswer が空=パス。q.answer(AIの解答)にフォールバックしてはいけない。
202
+ const ans = buzzer === "human" ? (humanAnswer || T("pass")) : (followUp ? followUp.answer : null);
203
  const res = buzzer === "human" ? result : (followUp ? { correct: followUp.correct } : null);
204
  return (
205
  <section className="col human" style={{ "--accent": "var(--hu)" }}>
 
215
 
216
  <div className="col-mid human-body">
217
  {answering ? (
218
+ <AnswerInput onSubmit={onAnswer} onPass={onPass} timeLeft={timeLeft} />
219
  ) : !humanAnswered ? (
220
  <React.Fragment>
221
  <div className={"buzzer-ready" + (live ? " armed" : "") + ((phase === "buzzed" && buzzer === "human") ? " hit" : "")}
 
246
  }
247
 
248
  function CenterBoard({ scores }) {
249
+ // 主表示はポイント(早押しボーナス 1.0〜1.5・誤答 −1.5 込み)。
250
+ // 正解数だけだと「ギリギリまで聞いて押す」が常に最適になり、早押しの意味が消えるため。
251
+ const fmt = (v) => (Math.round(v * 10) / 10).toFixed(1);
252
  return (
253
  <aside className="center">
254
  <div className="score-grid">
255
+ <div className="score-num ai">{fmt(scores.ai.pts)}
256
+ <span className="score-sub">{scores.ai.correct} {T("correctN")}</span>
257
+ <small>{T("ai")}</small></div>
258
  <div className="vs">{T("vs")}</div>
259
+ <div className="score-num hu">{fmt(scores.human.pts)}
260
+ <span className="score-sub">{scores.human.correct} {T("correctN")}</span>
261
+ <small>{T("human")}</small></div>
262
  </div>
263
+ <div className="score-unit">{T("pts")}</div>
264
  </aside>
265
  );
266
  }
static/i18n.js CHANGED
@@ -12,6 +12,7 @@
12
  "boot.start": "対戦スタート",
13
  "boot.generating": "実モデルで対戦を生成中…(gemma が思考しています)",
14
  "boot.almost": "まもなく完了…",
 
15
  "boot.replay": "▶ リプレイ",
16
  "boot.newMatch": "✦ 新しい対戦",
17
  "boot.liveLabel": "Quiz Buzzer AI · ライブ",
@@ -42,6 +43,26 @@
42
  // rebound(解答権の移動・両者誤答)
43
  "rebound.aiMissed": "AI が誤答", "rebound.yourChance": "あなたに解答権!",
44
  "rebound.aiTurn": "AI の解答権 →", "rebound.correctIs": "正解は",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  },
46
  en: {
47
  "boot.title": "Quiz Buzzer AI",
@@ -51,6 +72,7 @@
51
  "boot.start": "Start match",
52
  "boot.generating": "Generating the match with real models… (gemma is thinking)",
53
  "boot.almost": "Finishing up…",
 
54
  "boot.replay": "▶ Replay",
55
  "boot.newMatch": "✦ New match",
56
  "boot.liveLabel": "Quiz Buzzer AI · Live",
@@ -75,6 +97,20 @@
75
  "replay": "Replay",
76
  "rebound.aiMissed": "AI missed", "rebound.yourChance": "your chance!",
77
  "rebound.aiTurn": "AI’s rebound →", "rebound.correctIs": "Correct answer",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  },
79
  };
80
 
 
12
  "boot.start": "対戦スタート",
13
  "boot.generating": "実モデルで対戦を生成中…(gemma が思考しています)",
14
  "boot.almost": "まもなく完了…",
15
+ "boot.retry": "もう一度試す",
16
  "boot.replay": "▶ リプレイ",
17
  "boot.newMatch": "✦ 新しい対戦",
18
  "boot.liveLabel": "Quiz Buzzer AI · ライブ",
 
43
  // rebound(解答権の移動・両者誤答)
44
  "rebound.aiMissed": "AI が誤答", "rebound.yourChance": "あなたに解答権!",
45
  "rebound.aiTurn": "AI の解答権 →", "rebound.correctIs": "正解は",
46
+ // 解答制限時間
47
+ "timeLeft": "残り",
48
+ // AI確信度メーター・押下予定
49
+ "confLabel": "AI確信度", "aiPlanned": "AI予定 ‹ ",
50
+ // スコア(ポイント表示)
51
+ "pts": "pt", "correctN": "正解",
52
+ // roundover(振り返り・導線)
53
+ "recap": "振り返り", "watchReplay": "観賞リプレイ",
54
+ "colQ": "問題", "colTruth": "正解", "colAI": "AI", "colYou": "あなた",
55
+ "share": "結果をXでシェア",
56
+ "share.text": "日本語早押しクイズAI「Quiz Buzzer AI」と対戦!結果: 自分 {h} − {a} AI({hp}pt vs {ap}pt)",
57
+ // エラー(人間語)
58
+ "err.quota": "GPUの利用枠を使い切ったようです。Hugging Face にログインすると枠が増えます。少し時間を置いて、もう一度お試しください。",
59
+ "err.server": "対戦の生成に失敗しました。GPU が混み合っている可能性があります。もう一度お試しください。",
60
+ "err.net": "サーバーに接続できませんでした。通信環境を確認して再試行してください。",
61
+ // 生成待ちのティップス
62
+ "tip.1": "ルール: 問題の読み上げ中、AIより早く BUZZ(Space/タップ)すれば解答権はあなたのもの。",
63
+ "tip.2": "スコア: 正解 +1.0〜1.5(早押しほど高得点)/誤答 −1.5。誤答すると相手に解答権が移ります。",
64
+ "tip.3": "AIの中身: buzz判定は自作1.2B回帰ヘッド、解答は gemma-4-26B-A4B(日本語クイズSFT済)が毎問リアルタイム生成。",
65
+ "tip.4": "ヒント: 左カラムの「AI確信度」メーターがθに迫ったらAIが押す合図。先に押すなら今!",
66
  },
67
  en: {
68
  "boot.title": "Quiz Buzzer AI",
 
72
  "boot.start": "Start match",
73
  "boot.generating": "Generating the match with real models… (gemma is thinking)",
74
  "boot.almost": "Finishing up…",
75
+ "boot.retry": "Try again",
76
  "boot.replay": "▶ Replay",
77
  "boot.newMatch": "✦ New match",
78
  "boot.liveLabel": "Quiz Buzzer AI · Live",
 
97
  "replay": "Replay",
98
  "rebound.aiMissed": "AI missed", "rebound.yourChance": "your chance!",
99
  "rebound.aiTurn": "AI’s rebound →", "rebound.correctIs": "Correct answer",
100
+ "timeLeft": "Left",
101
+ "confLabel": "AI confidence", "aiPlanned": "AI planned ‹ ",
102
+ "pts": "pt", "correctN": "correct",
103
+ "recap": "Recap", "watchReplay": "Watch replay",
104
+ "colQ": "Question", "colTruth": "Truth", "colAI": "AI", "colYou": "You",
105
+ "share": "Share on X",
106
+ "share.text": "I challenged Quiz Buzzer AI (Japanese buzz-quiz AI)! Result: me {h} − {a} AI ({hp}pt vs {ap}pt)",
107
+ "err.quota": "Looks like the free GPU quota ran out. Signing in to Hugging Face raises your quota — please wait a bit and try again.",
108
+ "err.server": "Failed to generate the match — the GPU may be busy. Please try again.",
109
+ "err.net": "Could not reach the server. Check your connection and retry.",
110
+ "tip.1": "Rule: while the question is being read, buzz (Space / tap) before the AI to claim the answer.",
111
+ "tip.2": "Score: correct +1.0–1.5 (earlier buzz = more points) / wrong −1.5. A miss hands the rebound to your opponent.",
112
+ "tip.3": "Under the hood: buzz timing = our own 1.2B regression head; answers = gemma-4-26B-A4B (Japanese quiz SFT), generated live per question.",
113
+ "tip.4": "Tip: when the AI-confidence meter on the left nears θ, the AI is about to buzz. Beat it!",
114
  },
115
  };
116
 
static/index.html CHANGED
@@ -54,6 +54,12 @@
54
  border:0;border-radius:999px;padding:14px 34px;cursor:pointer;}
55
  #boot button#start:disabled{opacity:.5;cursor:default;}
56
  #boot .status{margin-top:18px;font-family:"DM Mono",monospace;font-size:13px;color:#6d6a62;min-height:18px;}
 
 
 
 
 
 
57
  .spin{display:inline-block;width:14px;height:14px;border:2px solid #6d6a62;border-top-color:#f3f1ea;
58
  border-radius:50%;animation:sp 0.8s linear infinite;vertical-align:-2px;margin-right:8px;}
59
  @keyframes sp{to{transform:rotate(360deg);}}
@@ -103,19 +109,22 @@
103
  <div class="bar"><div class="fill" id="prog-fill"></div></div>
104
  <div class="meta"><span id="prog-label"></span><span class="pct" id="prog-pct">0%</span></div>
105
  </div>
 
106
  </div>
107
  </div>
108
 
109
  <div class="nav" id="nav">
110
  <span class="lbl" id="nav-lbl">Quiz Buzzer AI · Live</span>
111
  <span class="sep"></span>
 
 
112
  <button id="btn-replay">▶ Replay</button>
113
  <span class="sep"></span>
114
  <button id="btn-new">✦ New match</button>
115
  </div>
116
 
117
- <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin></script>
118
- <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin></script>
119
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin></script>
120
 
121
  <!-- i18n(プレーン script。babel スクリプトより前に window.T / window.__lang を用意) -->
@@ -146,7 +155,7 @@
146
 
147
  <script>
148
  // ── 状態 ──────────────────────────────────────────────
149
- let rendered = false;
150
  let selectedGenre = null; // null = おまかせ(全ジャンル)
151
 
152
  // ── boot 画面の文言を現在の言語で描画 ──────────────────
@@ -208,6 +217,14 @@
208
  const s = Math.ceil(remainSec);
209
  return (window.__lang === "en") ? ("~" + s + "s remaining") : ("残り 約 " + s + " 秒");
210
  }
 
 
 
 
 
 
 
 
211
  // ── 実モデルで1マッチ生成(QUIZ_ROUND)。tqdm 風に %/残り時間を表示 ──────────
212
  async function fetchRound(){
213
  const status = document.getElementById("status");
@@ -217,6 +234,7 @@
217
  const pct = document.getElementById("prog-pct");
218
  const plabel = document.getElementById("prog-label");
219
  startBtn.disabled = true;
 
220
  status.innerHTML = '<span class="spin"></span>' + T("boot.generating");
221
 
222
  // 自己較正 ETA: 前回の実測ビルド秒(_build_seconds)を localStorage に保持。
@@ -226,6 +244,16 @@
226
  if(v > 0) eta = Math.min(90, Math.max(6, v)); }catch(e){}
227
  prog.style.display = "block";
228
  fill.style.width = "0%"; pct.textContent = "0%"; plabel.textContent = etaText(eta);
 
 
 
 
 
 
 
 
 
 
229
  const t0 = performance.now();
230
  const timer = setInterval(() => {
231
  const el = (performance.now() - t0) / 1000;
@@ -237,7 +265,7 @@
237
  fill.style.width = p + "%"; pct.textContent = p + "%";
238
  plabel.textContent = etaText(eta - el);
239
  }, 150);
240
- const stop = () => clearInterval(timer);
241
 
242
  try{
243
  const r = await fetch("/api/round", {method:"POST", headers:{"content-type":"application/json"},
@@ -256,8 +284,17 @@
256
  }catch(e){
257
  stop();
258
  prog.style.display = "none";
259
- status.innerHTML = "⚠ "+e.message;
 
 
 
 
 
 
 
 
260
  startBtn.disabled = false;
 
261
  return false;
262
  }
263
  }
@@ -277,16 +314,23 @@
277
  function renderStage(){
278
  document.getElementById("boot").style.display = "none";
279
  navActive = true; // 既定は非表示・マウス移動で右下に一時表示
280
- if(!rendered){
281
- // プレーン script なのJSX は使えない(SyntaxError でクリックリスナーご死ぬ)。
282
- // React.createElement生成する。
283
- ReactDOM.createRoot(document.getElementById("root")).render(
284
- React.createElement(window.BigScreenApp));
285
- rendered = true;
286
- }else if(window.__replay){
287
- window.__replay();
288
- }
289
  }
 
 
 
 
 
 
 
 
 
 
290
  // 3・2・1・Go! の演出(各 ~0.85s)。Promise で完了を待ってから big-screen を描画。
291
  function runCountdown(){
292
  return new Promise((resolve) => {
@@ -310,6 +354,14 @@
310
  if(await fetchRound()){ await runCountdown(); renderStage(); }
311
  });
312
  document.getElementById("btn-replay").addEventListener("click", () => window.__replay && window.__replay());
 
 
 
 
 
 
 
 
313
  document.getElementById("btn-new").addEventListener("click", () => {
314
  navActive = false;
315
  const nav = document.getElementById("nav");
 
54
  border:0;border-radius:999px;padding:14px 34px;cursor:pointer;}
55
  #boot button#start:disabled{opacity:.5;cursor:default;}
56
  #boot .status{margin-top:18px;font-family:"DM Mono",monospace;font-size:13px;color:#6d6a62;min-height:18px;}
57
+ #boot .err-main{font-family:"Noto Sans JP",sans-serif;font-size:14px;color:#e8b9b4;line-height:1.7;max-width:560px;margin:0 auto;}
58
+ #boot .err-raw{font-size:11px;color:#6d6a62;margin-top:8px;word-break:break-all;max-width:560px;margin-left:auto;margin-right:auto;}
59
+ /* 生成待ちティップス(ルール・モデル解説のローテーション) */
60
+ #tips{display:none;margin:22px auto 0;max-width:560px;font-family:"Noto Sans JP",sans-serif;
61
+ font-size:13px;line-height:1.8;color:#a8a49a;background:rgba(255,255,255,.04);
62
+ border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:13px 18px;min-height:44px;}
63
  .spin{display:inline-block;width:14px;height:14px;border:2px solid #6d6a62;border-top-color:#f3f1ea;
64
  border-radius:50%;animation:sp 0.8s linear infinite;vertical-align:-2px;margin-right:8px;}
65
  @keyframes sp{to{transform:rotate(360deg);}}
 
109
  <div class="bar"><div class="fill" id="prog-fill"></div></div>
110
  <div class="meta"><span id="prog-label"></span><span class="pct" id="prog-pct">0%</span></div>
111
  </div>
112
+ <div id="tips"></div>
113
  </div>
114
  </div>
115
 
116
  <div class="nav" id="nav">
117
  <span class="lbl" id="nav-lbl">Quiz Buzzer AI · Live</span>
118
  <span class="sep"></span>
119
+ <button id="btn-mute" title="Mute" aria-label="Mute">🔊</button>
120
+ <span class="sep"></span>
121
  <button id="btn-replay">▶ Replay</button>
122
  <span class="sep"></span>
123
  <button id="btn-new">✦ New match</button>
124
  </div>
125
 
126
+ <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
127
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
128
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin></script>
129
 
130
  <!-- i18n(プレーン script。babel スクリプトより前に window.T / window.__lang を用意) -->
 
155
 
156
  <script>
157
  // ── 状態 ──────────────────────────────────────────────
158
+ let rootHandle = null; // React root(マッチごとに key 付き remount)
159
  let selectedGenre = null; // null = おまかせ(全ジャンル)
160
 
161
  // ── boot 画面の文言を現在の言語で描画 ──────────────────
 
217
  const s = Math.ceil(remainSec);
218
  return (window.__lang === "en") ? ("~" + s + "s remaining") : ("残り 約 " + s + " 秒");
219
  }
220
+ // エラーを人間語に(ZeroGPU quota / GPU混雑 / ネットワーク)。生メッセージは小さく併記。
221
+ function friendlyError(msg){
222
+ const m = String(msg || "").toLowerCase();
223
+ if(m.includes("quota") || m.includes("zerogpu") || m.includes("429")) return T("err.quota");
224
+ if(m.includes("failed to fetch") || m.includes("networkerror") || m.includes("load failed"))
225
+ return T("err.net");
226
+ return T("err.server");
227
+ }
228
  // ── 実モデルで1マッチ生成(QUIZ_ROUND)。tqdm 風に %/残り時間を表示 ──────────
229
  async function fetchRound(){
230
  const status = document.getElementById("status");
 
234
  const pct = document.getElementById("prog-pct");
235
  const plabel = document.getElementById("prog-label");
236
  startBtn.disabled = true;
237
+ startBtn.textContent = T("boot.start"); // 前回エラーで「再試行」表記になっていても戻す
238
  status.innerHTML = '<span class="spin"></span>' + T("boot.generating");
239
 
240
  // 自己較正 ETA: 前回の実測ビルド秒(_build_seconds)を localStorage に保持。
 
244
  if(v > 0) eta = Math.min(90, Math.max(6, v)); }catch(e){}
245
  prog.style.display = "block";
246
  fill.style.width = "0%"; pct.textContent = "0%"; plabel.textContent = etaText(eta);
247
+ // 待ち時間にルール・モデル解説のティップスをローテーション表示(死に時間を初見教育に充てる)。
248
+ const tipsEl = document.getElementById("tips");
249
+ const TIP_KEYS = ["tip.1", "tip.2", "tip.3", "tip.4"];
250
+ let tipIdx = 0;
251
+ tipsEl.style.display = "block";
252
+ tipsEl.textContent = T(TIP_KEYS[0]);
253
+ const tipTimer = setInterval(() => {
254
+ tipIdx = (tipIdx + 1) % TIP_KEYS.length;
255
+ tipsEl.textContent = T(TIP_KEYS[tipIdx]);
256
+ }, 6000);
257
  const t0 = performance.now();
258
  const timer = setInterval(() => {
259
  const el = (performance.now() - t0) / 1000;
 
265
  fill.style.width = p + "%"; pct.textContent = p + "%";
266
  plabel.textContent = etaText(eta - el);
267
  }, 150);
268
+ const stop = () => { clearInterval(timer); clearInterval(tipTimer); tipsEl.style.display = "none"; };
269
 
270
  try{
271
  const r = await fetch("/api/round", {method:"POST", headers:{"content-type":"application/json"},
 
284
  }catch(e){
285
  stop();
286
  prog.style.display = "none";
287
+ // 人間語の説明+生メッセージ小表示(DOM 構築で HTML 注入を避ける)
288
+ status.textContent = "";
289
+ const main = document.createElement("div");
290
+ main.textContent = "⚠ " + friendlyError(e.message);
291
+ main.className = "err-main";
292
+ const raw = document.createElement("div");
293
+ raw.textContent = e.message;
294
+ raw.className = "err-raw";
295
+ status.appendChild(main); status.appendChild(raw);
296
  startBtn.disabled = false;
297
+ startBtn.textContent = T("boot.retry");
298
  return false;
299
  }
300
  }
 
314
  function renderStage(){
315
  document.getElementById("boot").style.display = "none";
316
  navActive = true; // 既定は非表示・マウス移動で右下に一時表示
317
+ // プレーン script なので JSX は使えない(SyntaxError でクリックリスナーごと死ぬ)。
318
+ // React.createElement生成する。マッチごとに key を変て remount しないと
319
+ // コンポーネントが古い window.QUIZ_ROUND を閉じ込めたまま=「新しい対戦」
320
+ // 同じ問題が再生されるバグになる。
321
+ if(!rootHandle) rootHandle = ReactDOM.createRoot(document.getElementById("root"));
322
+ rootHandle.render(React.createElement(window.BigScreenApp, {key: Date.now()}));
 
 
 
323
  }
324
+
325
+ // roundover 画面の「新しい対戦」: boot に戻って同じジャンル設定のまま即生成開始。
326
+ window.__newMatch = () => {
327
+ navActive = false;
328
+ document.getElementById("nav").classList.remove("show");
329
+ clearTimeout(navTimer);
330
+ document.getElementById("boot").style.display = "grid";
331
+ document.getElementById("status").textContent = "";
332
+ document.getElementById("start").click();
333
+ };
334
  // 3・2・1・Go! の演出(各 ~0.85s)。Promise で完了を待ってから big-screen を描画。
335
  function runCountdown(){
336
  return new Promise((resolve) => {
 
354
  if(await fetchRound()){ await runCountdown(); renderStage(); }
355
  });
356
  document.getElementById("btn-replay").addEventListener("click", () => window.__replay && window.__replay());
357
+ // ミュート(効果音+TTS読み上げ)。engine 側は __muteAll を見て audio.muted を切り替える。
358
+ document.getElementById("btn-mute").addEventListener("click", (e) => {
359
+ const on = !window.__muteAll;
360
+ window.__muteAll = on;
361
+ window.__muteSfx = on;
362
+ if(window.__curAudio) window.__curAudio.muted = on;
363
+ e.currentTarget.textContent = on ? "🔇" : "🔊";
364
+ });
365
  document.getElementById("btn-new").addEventListener("click", () => {
366
  navActive = false;
367
  const nav = document.getElementById("nav");
static/sfx.js CHANGED
@@ -50,6 +50,20 @@
50
  } catch (e) {}
51
  };
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  // 判定結果に応じて鳴らすショートカット。
54
  window.playResult = function (correct) {
55
  if (correct) window.playCorrect(); else window.playWrong();
 
50
  } catch (e) {}
51
  };
52
 
53
+ // 早押し(buzz-in): 立ち上がりの2音「ピッポン」。人間=高め/AI=低めで識別できる。
54
+ window.playBuzz = function (side) {
55
+ if (window.__muteSfx) return;
56
+ try {
57
+ if (side === "human") {
58
+ tone(660, 0.00, 0.07, "square", 0.20);
59
+ tone(990, 0.06, 0.16, "square", 0.22);
60
+ } else {
61
+ tone(440, 0.00, 0.07, "square", 0.20);
62
+ tone(660, 0.06, 0.16, "square", 0.22);
63
+ }
64
+ } catch (e) {}
65
+ };
66
+
67
  // 判定結果に応じて鳴らすショートカット。
68
  window.playResult = function (correct) {
69
  if (correct) window.playCorrect(); else window.playWrong();