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 +60 -0
- app.py +10 -3
- questions_ja.json +1 -49
- questions_pool_ja.json +0 -0
- round_builder.py +29 -11
- static/bigscreen-engine-en.jsx +155 -44
- static/bigscreen-style-en.js +71 -0
- static/bigscreen-ui-en.jsx +62 -13
- static/i18n.js +36 -0
- static/index.html +66 -14
- static/sfx.js +14 -0
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 |
-
"
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 309 |
setReveal({ truth: qq.truth });
|
| 310 |
if (autoplay) at(3800, () => advance(idx));
|
| 311 |
return;
|
| 312 |
}
|
| 313 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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" &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
</div>
|
| 363 |
);
|
| 364 |
}
|
| 365 |
|
| 366 |
-
function RoundOver({ scores, onReplay }) {
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
return (
|
| 369 |
-
<div
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 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
|
| 380 |
-
<span style={{ color: "var(--ai)" }}>{scores.ai.
|
| 381 |
-
<span
|
| 382 |
-
<span style={{ color: "var(--hu)" }}>{scores.human.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 212 |
<div className="vs">{T("vs")}</div>
|
| 213 |
-
<div className="score-num hu">{scores.human.
|
|
|
|
|
|
|
| 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.
|
| 118 |
-
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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();
|