九九版と長さ版を独立に作った結果、約8割が同一コードになった。 認証・ランキング・キャッシュ・排他制御・キー配置・未送信データの退避—— どれも単元とは無関係な仕組みなのに、単元ごとに複製されていた。 3つ目を作れば、バグ修正のたびに3か所を直すことになる。
そこで 共通エンジン(Core)と単元定義(Unit)に分離した。
新しい単元を作るときに書くのは、原則 Unit.gs 1枚だけになる。
共通エンジン ── 認証 / 出題の配信と再採点 / 記録 / ランキング / 分析の器 / 画面
↑
単元定義 ── モード一覧 / 1問の作り方 / 分析の軸
Core は Unit を知らない。Unit が Core の期待する形に合わせる。 逆向きの依存を作らないことで、単元を足しても Core が肥大しない。
Core.gs ──uses──▶ UNIT (グローバル定数)
index.html ──uses──▶ boot() が返す設定(単元名・モード一覧)
すべての単元が、この1つの形で問題を表す。これが分離の要。
{
t: 'A', // 問題型。分析の軸になり、画面側の表示分岐にも使う
q: ['1200', 'm'], // 出題の表示トークン
f: ['km', 'm'], // 答えの欄と、その単位
ans: { km: 1, m: 200 }, // 正解
tag: '1200m', // 重複回避と分析用の識別子
ruler: { from:0, span:85, at:64 }, // 任意。あれば目盛りを描く
rows: [['73','÷','8','='], ['八','_','_'], ['73','-','72','=','_']], // 任意。並べ方を単元が決める
veil: 2 // 任意。n番目以降の欄がある段を、そこに着くまで覆う
}
f: [''] とし、単位ラベルを空にするruler があれば Core の画面が SVG で目盛りを描く。長さ以外(重さ・かさ)でも使えるq に + - × ÷ を混ぜると演算子として描画されるq の中で UNIT.units のキーと一致するトークンは単位として描画される。
画面(index.html)は単位名を知らないので、単元が持つ単位はすべて UNIT.units に列挙する
(例: { km: '#F1B1E4', m: '', cm: '#8A6CE5' }。色は学年ごとに使ってよい別学年の色から選ぶ(docs/ADD_UNIT.md「学年の色」)。空文字で既定色)t は画面側が型判定に使う(例: 2段組にするか、ふりがなの案内を出すか)。
桁数や ruler の形から画面側が型を推測してはいけない。推測は必ず外れるケースを生む既定の描画は 「式 = 欄」に固定されている。q が左辺に並び、答えの欄は必ず右端に集まる。
この形では作れないものが2つある。
□ = 4 × 8)八 □ □ / 73 − 72 = □)共通画面に単元ごとの分岐を足して解くと、依存が逆流する。 以前 index.html が
「モード5は2桁」という長さ単元固有の分岐を持っていたのと同じ失敗になる。
そこで digitCap と同じく単元が宣言する形にした。問題が rows を持てば、
共通画面はその並びのとおりに描くだけで、意味は知らない。
veil: n は「n番目以降の欄がある段を、そこに着くまで覆う」。
覆いの向こうに前の段の答えを使って書いた式を置けるようにするためのもので、
先に読めると手続きを飛ばして答えが出てしまう段を隠す。
このため、覆いより手前の欄は正解でないと越えられない。既定の欄移動は 「上限桁まで打てば進む」だが、それだと誤った値のまま次の段が開き、 その段の式と画面の中で食い違う。打ち切った時点で1回のまちがいとして落とす。
UNIT.slotColor は欄キーごとの「目立たせる色」で、宣言された欄に
色・下線の太さ・薄い塗りをまとめて付ける。色だけに意味を持たせないという決まりを
共通画面の側で守るため、3つは分けて指定できない(単元が色だけを宣言することはできない)。
UNIT.glyph は欄キーごとの字形の表で、宣言された欄の数字だけその字で描く
(九九の商を漢数字にするなど)。打つのは数字キーのままで、判定も記録も数字。
digitCap と同じく宣言であって、答えは含まない。
| 方式 | 使う場面 | 例 |
|---|---|---|
| 欄ごとの一致 | 位に分けること自体が目標 | 1200m = □km□m |
| 合計での一致 | 値を読むことが目標 | まきじゃく(1m87cm も 187 も正解) |
UNIT.byTotal(item) が true を返す型は合計で判定する。
空欄は 0 として扱うので、0km400m と「km欄を空にして400」が両方通る。
答えの桁数が確定していれば、Enter なしで自動確定できる。 ただし桁数そのものがヒントになるため、出題側で桁数を揃える責任を負う。
□km□m にするなら 0km も出す(1km以上かを漏らさない)確定に使う桁数は UNIT.digitCap で宣言する。 Core はこの宣言だけを見て、
その問題の実際の答え(ans)は参照しない。
digitCap: { 1: { '': 2 }, 5: { cm: 2 } } // モードid → 欄キー → 桁数
実際の答えの桁数で確定させると2つ壊れる。1つは、桁数の大小が 「早く確定する/しない」という形でヒントに変わること。もう1つが重い。 答えが1桁の問題は1打で確定し、2桁の問題は2打かかる。 主指標である所要msに、問題の種類ごとの打鍵数の差が混入する。
九九がこれを踏んでいた。9×9 の積は1〜81 で、1桁の答えが81通り中23通り(28%)。
1桁の答えを多く含む段(1・2の段)だけが速く見えていたが、
それは想起が速いのではなく打鍵が1回少ないだけだった。
weak_child は段ごとの平均想起時間を主指標にしているので、
この下駄は分析の結論を直接ゆがめる。
digitCap が無ければ従来の「正解の桁数 or 欄の上限」で確定する。
既存単元との互換のために残してあるが、新しい単元では必ず宣言する。
この宣言を Unit 側に置いたのは依存の向きを保つため。 以前は index.html に
「モード5は2桁、モード6は3桁」という長さ単元固有の分岐が直接書かれていた。
共通画面が特定の単元のモード番号を知っている状態で、
他の単元が ruler を使うと壊れる。宣言に置き換えて解消した。
| 脅威 | 対策 |
|---|---|
| スコアの改竄 | 点数を送らせない。解答だけ送り、サーバーがシードから問題を再生成して再採点 |
| 他人になりすまし | トークンにメールを入れて署名し、送信時に照合 |
| 点数の水増し | 出題番号の重複・範囲外・負の ms を弾く(itemsValid_) |
| 制限時間の詐称 | 制限時間をセッション開始時の値としてトークンに固定 |
| シートの直接編集 | デプロイを「実行=自分/アクセス=ドメイン内」にし、児童に権限を渡さない |
| 教師画面への侵入 | 教師ドメイン(@edu.nishi.or.jp)・所有者・config.teachers のみ |
完全ではない。開発者ツールを開ける児童は突破しうる。
log に1問ごとの所要msが残るので、全問200ms未満のような異常値は事後に検出できる。
教師は @edu.nishi.or.jp、児童は @kyoiku.edu.nishi.or.jp。
児童のドメインは教師ドメインのサブドメインになっている。
tanaka@edu.nishi.or.jp → 教師
a1234@kyoiku.edu.nishi.or.jp → 児童(教師ではない)
indexOf('edu.nishi.or.jp') >= 0 や endsWith('edu.nishi.or.jp') で書くと、
児童が全員教師になる。weak_child(誰がどの型で遅いか)まで児童の画面から開けてしまい、
README が「児童に見せない前提」としているものが崩れる。
@ の右側を切り出して TEACHER_DOMAIN と完全一致で比べる(isTeacherDomain_)。
config.teachers は例外の口として残す。別ドメインの教師(支援員・非常勤など)を1件ずつ足す。
判定はサーバー側だけで行い、教師画面の URL は教師と判定したときにしか 児童画面へ渡さない。児童のブラウザにリンクの文字列自体が届かない。
どのモードを児童に見せるかは、教師がクラスごとに指定する。
class_config シートが class | mode_1 | mode_2 | … | seq_off を持ち、
チェックの入ったモードだけが児童の一覧に出る。
以前はこれを「フラグ」で持っていた。単元が flags を宣言し、
モードが flag: 'calc' のように1つのフラグを参照する形で、
フラグは学年で自動的に開き、クラスごとに上書きできた。
この形は、モードよりフラグが粗いため要求に応えられない。
複数のモードが同じフラグを共有するので片方だけ閉じることができず、
flag: null のモードには閉じる手段がそもそも無い。
実際、4単元20モードのうち単独で閉じられるものは1つも無かった。
学年による自動の解禁も廃した。担任が触っていないのにモードが開いている状態を作らない。 どのモードを出すかは進度の判断で、学年から機械的に決まるものではない。 初期状態は全モードがオフなので、設置後に教師画面で開く操作が要る。
Unit.gs は公開の可否を持たない。単元は「どんなモードがあるか」だけを宣言し、
「どれを使うか」は現場が決める。
boot() は起動時の1回しか呼ばれない。教師が授業中に公開しても、
画面を開いたままの端末は再読み込みするまで空のまま残る。
帯活動の途中で40人に「もう一回ひらいて」と言わせるのは、公開の操作としては使えない。
そこで 公開が0件のときだけ 15秒ごとに boot() を取り直す
(index.html の watchRun)。1件でも開けば見張りは止まり、以後は通信が増えない。
プレイ中の画面と、ふたを閉じた端末(document.hidden)では叩かない。
負荷は「まだ開いていない端末の数」で頭打ちになり、教師が公開した瞬間に0へ戻る。
40台が同じ授業の頭に開くと巡回の秒が揃うので、間隔に0〜5秒のばらつきを足してある。
10分(40回)で打ち切る。閉じたまま放置された端末が一日中叩き続けるのを防ぐため。
打ち切ったあとは案内文を「さいど よみこんで ください」に替え、
タブに戻ってきた操作(visibilitychange)を「まだ待っている」の合図として数え直す。
型を積み上げる単元(あまりのあるわり算の ①九九さがし → ②誤答判別 → ③わり算)は、
前のモードを一定回数やるまで次を開けない。Unit.gs が宣言する。
{ id: 2, needs: { mode: 1, tries: 3 } }
Core は needs という形だけを見る。単元名も型名も入らない。
条件を「回数」にしてある。「①で n 問以上」のような成績条件にすると、 到達バッジを却下したのと同じ欠陥が戻る——閾値の適正値が学級ごとに違い、 上位層はすぐ開き、下位層は最後のモードに永久に到達しない。 回数なら遅い子でも必ず到達し、順序だけが担保される。
class_config の seq_off にチェックを入れると、そのクラスだけ順序を外せる。
授業で全員に同じモードをやらせる場面があるので、担任が上書きできる口を残す。
この列は needs を持つ単元の教師画面にだけ出る。
tries を合算する。制限時間も本番/練習も問わない
(「何回やったか」の条件を、制限時間の設定で塞がないため)startSession / nextPracticeItem)。
localStorage の古い控えで開いて見えても通らないPAGES(タブ名 → 区画の id)を1か所に持ち、showPage が区画の出し入れをする$=document.getElementById の短い書き方)と背景の床のプレビュー → ②見出し・タブ・全般設定・公開モード・分析 →
③協力プレイ。<script> をまたいで呼べるのは関数だけなので、読み込み時に動く文は使う関数より後のブロックに置く.pick/大きな操作を実行する=立体のキー .bkey(保存・開始は濃紺 .go、終了は朱 .stop)/
小さく変える=灰色の札 .bstep(−+は丸)/映し方を大きく変える=青の塗り .bviewapps/hub/teacher.html・analysis.html)も同じ型:見出しは1段(題名|タブ|写しの名前・学級など|行き先・集計)、
タブは同じ下線の型、ボタンは同じ4つの役割のクラス。別の GAS プロジェクトなので CSS は各ファイルに写して持つ(tests/hub_ui.test.cjs が確かめる)display:none と、位置や色が値で決まるもの(起点の印・名札の色など)だけ共通の設定(制限時間・閾値など)は BASE_DEFAULTS にある。
単元固有の設定は UNIT.settings で宣言すると、教師画面に欄が自動で生まれる。
settings: [{ key: 'ruler_tape', label: 'テープの太さ', min: 60, max: 200 }]
Core も teacher.html も key の意味を知らない。宣言した単元にだけ欄が出る
(長さの巻き尺設定は九九の教師画面に出ない)。欄を直接 teacher.html に
書き足すと、共通画面が単元を知ることになり依存が逆流する。
設定は表示にだけ効かせる。 gen は rand と mode しか受け取らない。
教師が変えた値で出題が変わる作りにすると、サーバーの再採点と食い違ってスコアが壊れる。
長さの巻き尺で「表示範囲(span)」を設定項目にしていないのはこのため。
span は目盛りの窓と答えの値域の両方を決めているので、表示だけを変えられない。
最初のメニューの背景に、練習を重ねるほど育つ非周期の床を敷ける。
単元が settings で floor_on(onoff)と floor_per_answer(1正答あたりの育ち)を宣言したときだけ動く。
図形は単元が UNIT.floorPattern で選ぶ(FLOOR_PATTERNS_ の id。既定は penrose)。
今は九九(八角の星)・あまりのあるわり算(ペンローズ)・長さのたんい(巻き尺の渦)・おもさ(円弧の曼荼羅)・円と球(円と球:起点2つ)が宣言している。
タイルは穴(h)を持てる。画面に掛かるかはタイルの外接する四角で判定する。
タイルは育つ順の値(o、0〜1)も持てる。o を持つ図形は、育ち始める点からの距離ではなく o の順に育つ(起点が複数の図形。
円と球は、円の模様と球の模様それぞれの中の距離の順位を枚数で割った値で、2つが同じ割合ずつ交互に育つ)。①の先のにじみは枚数(次の60枚)で付ける。
生成器は design リポジトリの growing-figures/generators.js を common/index.html に写したもの。直すときは向こうを正本にする。宣言の無い単元では uset_ が 0 を返し、キャンバスは隠れたまま何も計算しない。
図形・育ち方・色の正本は design リポジトリの growing-figures/。ここにあるのはその実装。
floorPace_)。
床の枚数 = 標準の分数 × 20 × floor_per_answer。標準の分数 = Σ(モードごとの本番の累計正答 ÷ そのモードの1分あたり平均正答)。
20 は基準の速さ(1分20問。FLOOR_REF_PER_MIN_ と画面の FLOOR_REF)。1正答の枚数は floor_per_answer × 20 ÷ そのモードの平均。
基準をサイトの平均にしないのは、サイトをまたいで「解きにくいほど染まりやすい」を保つため。
1分あたり平均正答は、このサイトの summary の本番の行(全児童・全制限時間)から出す。
年度初めに値が暴れないよう、1分20問を10分ぶんの事前値を足してから割る(単元が floorPrior で変えられる)。
解きにくい問題を載せたサイト・モードほど1問で多く育ち、平均的な速さなら1分の練習でどのサイトも同じだけ育つ。
練習(記録しない・時間制限つき)は入れない(★と同じ理由)正答 ÷ そのモードの平均 を足し、localStorage の控えも書き換えるUNIT.floorColor で FLOOR_GEMS_(宝石の名の8色)から選ぶ。
薄く塗る背景で意味を伝える色ではないので GRADE_EXTRA_ には縛らない。学年の色と同じ色は validateUnit_ が落とす。
サイトごとの割り当ては design リポジトリの growing-figures/COLORS.md が台帳教師画面のプレビュー:床を宣言した単元では「全般設定」タブに、児童の Chromebook(1366×768)と同じ図形・同じ2色の床を縮めて描く箱が出る。
「1正答あたりの育ち」の入力欄を書きかえると保存前に反映し、「全部そろうまで約◯問(1分20問の速さで)」を出す。
生成器は2つ目の写しを置かず、index.html の 'FLOOR_GEN_BEGIN';〜'FLOOR_GEN_END';(何もしない文)の間を Core.gs の floorGenForTeacher_ が切り出して
teacher.html に埋め込む(このしくみ自体はファイルを増やさない)。design から写し直すときも印は残す。
印をコメントにしてはいけない:GAS の include(HtmlService の getContent())は JS のコメントを消して返すため、サーバーから見えなくなる
写し直しの手順(写し元のコミットは index.html の「写し元: … @
growing-figures/generators.js を直し、check.cjs を通してコミットするtests/fixtures/generators.<新sha>.js に保存する(古い原本は捨て、常にピンの1つだけ)index.html の 'FLOOR_GEN_BEGIN';〜'FLOOR_GEN_END'; の間を新しい原本で置きかえる。末尾の受け取りだけ })(this); を
})(window); // 写し元は (this)。この画面は strict なので window を渡す に変える(印は残す)node tests/floor.test.cjs(ピン一致を含む)を通す最も練習させたいモードほど、1正答で図形が多く育つようにする。教師画面の全般設定「モードごとの育ちの倍率」で 0.5〜3倍(既定1倍)。
保存先は config の grow_w(”モード:倍率” をカンマでつなぐ)。単元は UNIT.growWeights で既定を宣言できる(あまりのあるわり算は「あまりのある わり算」を2倍)。
growWeights_ が「単元の既定 → 教師の設定」の順に重ねて 0.5〜3 に丸める。
floorPace_(速さで揃えた1分あたりの正答)を倍率で割る。1正答の標準の分数が倍率ぶん大きくなり、児童の画面は今までどおり割って足すだけ。
床は累計の正答から毎回描くので、倍率を変えるとこれまでの正答の分も描き直されるgw。coop シートの17列目・保存データ。続きからは前の回のまま)。図形の枚数は「正答 × 1正答の枚数 × その正答のモードの倍率」。
自動の「1正答の枚数」は倍率の平均で見込む。せいかいの数と「じぶん N もん」は生の正答数のまま。見返し(coopReview)は各正答に倍率を付ける(e[4])クラス全員の本番の正答で1つの図形を育てる、教室の大画面(教師画面の「協力プレイ」タブ)だけに出す仕組み。教師画面のタブは「公開モード|協力プレイ|全般設定|分析」の順。 児童の画面はいつものまま(左上に自分の色のエンブレムと「協力プレイ中」が出るだけ。協力していない間は、終わった回があれば同じ場所に灰色の「前回の協力プレイ」)。
両画面に同じものが要る部品(色の割り当て・起点・割当・枚数の見込み・縮尺の定数)は common/coop_shared.html に1つだけ置き、
index.html と teacher.html が <?!= include('coop_shared') ?> で同じものを挿入する(貼るファイルは5つになる。写しを2か所で直し忘れる事故を無くすため)。
菱形のパス(floorPoly_)は床の FLOOR_UI 区画のものを両画面で共用する(教師画面には床のプレビュー用に同じ区画が埋め込まれている)。
印は画面(section)の外に置くので、メニュー・結果・プレイのどこに居ても出る(明るい画面では白い札)。
教師が始めたときに開いたままの画面にもすぐ出すため、児童側は画面を触っている間だけ10秒+ばらつきごとに
coopPeek(自分の色に必要な分だけ返す軽い問い合わせ)を聞く。無操作が5分続く端末・閉じた端末は聞かず、
次の操作や画面への復帰で即1回聞いて再開する(恒常的な通信を増やさない)。変化がある時だけ印を描き直す。
now + COOP_COUNTDOWN_MS(4秒)に置き(coopStart({ countdown: true }))、画面はその start と
サーバー時計のずれ(応答の now)から数字を出す。押してから応答が届くまで(GAS の往復で1〜3秒ばらつく)は長さが決まらないので、
数字ではなく「よーい…」を出し、応答の後の1秒も「よーい…」、残り3秒を 3→2→1 と1秒ずつ数える(以前は送信中から「5」を出し、5 だけが往復の分長かった)。
始まりを先に決めるので、混雑で開始に失敗しても「スタート」を出す前に分かる。
数えている表示は送信中の印とセッションの start だけから決めるので、ページを開き直しても同じ表示に戻る。
リセットも同じく数えてから始める。制限時間はスタートから数え、カウントダウン中に遊び終えた回は数えない。
countdown を渡さない呼び出し(古い画面)は今すぐ始まる。やりとりの形は後方互換なので ENGINE_VER は上げない。
全画面はブラウザごと(requestFullscreen。タブとアドレスバーも消す)。断られたらページいっぱいの表示で続ける。
全画面の間の Esc は keydown に届かないので、fullscreenchange で元の表示に戻すcoopAutoRateOf)。
完成を越えると以後の正答が画面に出ず「解いても何も起きない」になるので、見込みが外れても越えにくい側を狙う。
枚数 = 0.85 × 完成の枚数 ÷ 見込みの正答。完成の枚数は画面の大きさと図形から数える(coopFinalTiles。箱と全画面で違い、
枚数もそれに合わせて変わるので、育ちの割合はどちらで見ても同じ)。見込みの正答 = 人数(氏名の空いた人を除く)× 参加 0.9 ×
遊べる回数(予定の分 × 60 ÷(1回の秒 + 25秒))× 1回の正答(coopPace_:サイト全体の本番の1分あたりの正答。床と同じ下駄つき)。
参加 0.9 と間の25秒は根拠のない仮定。保存した回を再生しながら「1正答」を書きかえると終わりの形を試せるので、実際の回で確かめて直す。
速さ・予定の分・1回の秒はセッションと保存データに持たせ、再生でも開始時と同じ枚数になる。
入力欄に数を入れるとその数で固定(画面の大きさを変えても変わらない)、空にするか「自動にもどす」で自動に戻るcoopReplayPlace)。終わった回・保存データの再生・見本で使うsubmitSession、ロックの内側)を、ライブのイベント列キャッシュに1件足す(シートには書かない)。
教師画面は3秒ごとに coopState(since) で差分だけ取り、そのときにまだ書いていない分を coop_log へまとめて1回で書く(coopFlush_)。
クラス全員が同じ1分の終わりに送るので、提出ごとのシートへの書き込みがロックの時間を延ばし、混雑で弾かれる提出を増やしていたため。
まとめ書きも同じロックを取るが、0.5秒で諦めて次の回に回す(提出を待たせない)。
教師画面が閉じている間はまとめ書きが起きないので、書いていない分が40件を超えたら提出の側でも書く(キャッシュが消えたときに失う分を限る)。
キャッシュが消えてサーバーの列が手元より短くなったら、教師画面は差分をやめて全件を取り直す
児童側に push は要らないので「教師画面だけに反映」の設計にしたstartSession が置いた開始時刻+制限時間)で見る。
協力の「始まり+(制限秒−5秒)」から終了+10秒(カウントダウンと通信の遅れ)の間に遊び終えた回を数え、終了から10分を過ぎて届いたものは数えない。
始まりの下限を「始まり」ではなく「始まり+(制限秒−5秒)」にするのは、始まる前に遊び始めておき、始まった直後に遊び終えて
ほぼ1回分の正答を数えさせる抜け道を塞ぐため(始まりの5秒より前に始めた回は数えない。5秒は「よーい…」の間に押した回の分。COOP_EARLY_MS)。
混雑で弾かれた提出は次の起動で送り直されるが、届いた時刻で見ると終了後になって数えられず、混んだ瞬間に送った子に片寄るため。
遅れて届いた回は、画面の時間軸では終了の時点に置く(再生の順が崩れない)。
次の協力を始めても、直前の協力はその10分の間 coop_prev に残し、直前の協力の中で遊び終えた回の送り直しはそちらへ数える
(締め切り直後に「開始」を押すと、前の回の取りこぼしが出ていた)。リセットで途中から切り直したときは、前の協力の終わりをその時点にする。
締め切り後に増えた合計は、教師画面の問い合わせで coop シートの行にも追いつかせる
coopState が時刻を見て自動で done に閉じるので「おわる」の押し忘れでも閉まる!isPractice の内側だけで coopNote_ を呼ぶ。分析と同じ理由coopFit_)。高さが足りない時は操作欄の中を送り、ページは送らない。幅900px以下では上下に積むgi[])を「グループを編集…」のポップアップで直す(名前のチップを運んで「保存」)。
映している回(進行中・終わった回)があればその回の組(coopSetGroups)、無ければ開始前の予定を直す。
開始前にも作れる(同じポップアップ)。グループの予定の正本は単元のスプレッドシートの 「グループ分け」シート(以前の「組分け」シートは名前と見出しを付け替えて使う)
(クラス|グループ数|番号|氏名|グループ|email)。教師はシートを直接直しても、画面で名前のチップを運んで「保存」してもよい(どちらも同じシートに書く)。
画面の変更は「保存」を押すまでシートに書かない。クラス×グループ数ごとの行の塊で、児童はメールで照らす
(名簿の並びが変わってもずれない。メールが空なら番号。転入の児童だけ番号順の等分の位置に入る)。
以前の版はスクリプトプロパティ(coop_plan_<クラス>_<組数>)に持っていたので、シートに塊が無い時だけそちらを読み、保存した時にシートへ移す。開始時は予定 → 前回と同じ顔ぶれの編成 → 番号順の等分 の順で決め、
遊んでいる途中の編集(coopSetGroups)も予定へ書き戻す。開始前の欄はステージと別にしてある
(ステージは進行中・終わった回を3秒ごとに映し直すので、開始前の名簿を出すと取り合いになる)。組は文字(A組…)で示し、色は出さない(色は開始時のシードで決まる)
保存データは児童 index 単位のままで、組は表示時の写像。保存時と今の組、どちらでも見られるcoop シートの16列目・
保存データに持ち(coopOrgPts_ が数と範囲を確かめる)、続きから・リセットは前の回の位置。誰がどの起点を担当するかは位置を決めてもシードで混ぜる。
起点が1つでも、動かせばその点から近い順に育つ(動かしていなければ今までの並び)
手続き(coopOrigins / coopOrders / coopTake / coopFinOf)は teacher.html と index.html に同じものを置き、テストが一字一句照合する。
起点の数はセッション・coop シートの15列目・保存データに持ち、続きからとリセットは前の回の数を使う。グループの回で組を変えると組み直す。
起点を児童数にすると、島の大きさがその子の正答数になり、モニターと児童の見返しで見える(誰の正答が少ないかが分かる)。使う回は教師が選ぶCP.prev)
見本の表示⇄非表示:ステージ左上の「プレビューを見る」(押すたびに入⇄切。この端末だけ。localStorage.coopPrevOn、既定は切)。かくすと正答の無い見本(図形の輪郭と起点の印だけ)になり、
起点の印は引き続きつまんで動かせる。「見本」の札・再生のつまみ・時計は出さない(#coopStage.prevEmpty)closePath() で閉じず、始点(とその次の点)へ lineTo で戻す。多数の図形を1本の Path2D にまとめると
closePath() の回数に対して時間が二乗で伸び、終わった回の描き直しが約0.9秒かかっていた(4万枚で7.5秒)。今は約20msprior(前の回までの合計の長さ・1本の時間軸に並べ直した正答・回ごとの条件 segs)として新しいセッションに持たせる。
図形・色(シード)・クラス・色づかいは前の回に合わせ、時間だけ新しく選ぶ。前の回が続きからの回なら、その prior も含めて何回でも続けられる。
児童はメールで照らし直す(メールを持たない古い保存データは番号と氏名)。いなくなった児童の分は落とす。
「1正答の枚数」は回ごとに、その回を遊んだ時と同じ順で決め直す(前の回はその回の条件、続きの回は残りの余白で coopAutoRateOf の used)。
prior は coop シートの14列目にも置くので、キャッシュが消えても続きの部分を失わない。保存データには order(メール)と prior を入れるが、
coopLoad が画面へ返す時に order は落とすcoopSetReview → config.coop_rv = “3-1=s…,3-2=s…”)。
選ばないクラスはそのクラスの直前の回。正答が1つも無い回は見せない(札も出さない。試しに開始して終えた回が「前回」になって空の図が出るのを防ぐ)。
協力プレイをしていないクラスでは札を出さない。選んだ保存データの判定(クラス・合計・児童のメール)は小さな控え(coopRvHead_、キャッシュ)から読み、
coopPeek(開いている児童画面が何度も呼ぶ)で payload 全体を読まない。保存データが消えていたら直前の回に戻る。応答は直前の回と同じ形(coopRvOut_)でメールは入らない
右上に「みんなで N もん」を出す。「じぶん N もん」は教師画面の協力プレイタブ「図形と表示の設定」で出す設定にした時だけ出る(既定は出さない。config.coop_mine。
自分の数は届いた正答の列から画面で足すだけ=問い合わせは増やさない。続きからの回は前の回の分も含む。出さなくても正答の列は届くので図形は変わらない)。
できる操作は「自分(自分の組)の色を目立たせる」だけ。他の児童の色を選んで目立たせる手段は作らない(誰の正答が少ないかを児童どうしで見つけられないようにする)。
応答(coopReview)には名前・番号・メール・組の一覧を入れない。色はモニターと同じ(index はそのまま)。組の色づかいでは各正答に組番号だけを付ける。
色の割り当ては回ごとのシードで決まり、新しく始める・リセットするたびにシードを引き直すので、同じ児童・組でも回ごとに色が変わる(続きからの回だけ前の回のシードを使う)。
モニターで名前を出していた回は、見返しの色とモニターの名札を照らせば誰の分かが分かる。気になる回は「名前をかくす」で映す
描き方は教師画面の写し(index.html の CRV / coopRvAutoRate = teacher.html の定数と coopAutoRateOf。テストが照合する)。動きや再生はせず最後の形を1枚描くcoop_live キャッシュがライブの1件、coop シートは履歴。リセット=新しいセッション id で始め直すcoop_live が期限前に消えたら、coop シートの最後の行(児童の並びは名簿、合計は coop_log)から組み直す。
セッションが1つも無いときは null を置き、起動のたびにシートを読みに行かないsubmitSession)と同じスクリプトロックの内側で coop_live を書き換える。
重なると後から書いた方が前の変更を消すため(提出が組の編集を古い組で上書きする、など)。取れないときは「混んでいます」を返すsubmitSession の中の coopNote_ は try に包む。協力モードで失敗しても、採点の記録は成功として返すstartSession の coop.counts)。グループの色づかいでは「あなたの組の色」coopState の応答からメールアドレスは落とすGAS はサンドボックスの起動と boot() の1往復(300〜800ms)が終わるまで応答しない。
以前はその間 show() が呼ばれず、児童は真っ白な画面を見ていた。
実時間以上に遅く感じる構造で、遅さの体感の大半がこれだった。
対策は2つ。
boot() 結果を localStorage に控え、次回は先に描いてから上書きする。
2回目以降はメニューが往復ゼロで出る。初回は「よみこみ中…」を出し、空白は見せないstartSession の modeAllowed_ が弾く。
表示だけが最大1往復ぶん古くなる控えは単元ごとに分けて持つ(キーに UNIT.id を入れる。doGet がテンプレートとして評価するときに APP_ID として埋め込む)。
以前はデプロイURL(location.pathname)で分けていたが、GAS のウェブアプリはどの単元も iframe の中の同じパス(/userCodeAppPanel)で動くので分けられず、
あまりのあるわり算を開くと九九の控えが先に描かれ、サーバーの応答が失敗すると九九のまま残っていた。未送信の記録(km_pending_<単元>)も同じ理由で単元ごとに分ける。
画面を描く処理は、DOM要素が無くても落ちないように書く。 起動時の1行が例外を投げると、
以降が全部止まって白画面になり、原因も見えない。実際 $('apptitle') という
存在しない id への参照(正しくは class="apptitle")が1つあり、これで全画面が死んでいた。
QN = 200(1セッションの出題数)は多く見えるが、実測で1セッション7〜15KB。
ruler を積んでも問題にならない。遅さの原因をここに求めない。
LockService で直列化nextPracticeItem を呼ぶ。
採点にも ms の測定にも使わない画面なので、通信コストの制約対象ではない。
1問だけ先読みしておくので、体感の待ちは出ない(詳細は common/index.html の pServe)localStorage に token ごとに積み、次回起動時と次の送信成功時に再送s2.<本体>.<署名>、鍵はスクリプトプロパティ sign_key)。
CacheService は保存を保証しないので、回の情報をキャッシュだけに置くと消えた回が採点できない。
キャッシュの印は「未記録」の速い判定にだけ使い、消えていたら used シートで二重記録を確かめる。
受付は7日(TOKEN_DAYS_)。used の古い行は resetDaily が消すconfig と roster はキャッシュ(60秒 / 300秒)。ロック保持時間を縮めるため代償:設定変更の反映が最大5分遅れる。教師画面の保存でキャッシュを破棄して即時化できる。
Apps Script の同時実行は1ユーザーあたり30。このアプリは「実行=自分(所有者)」で デプロイする(児童にスプレッドシートの編集権を渡さないため、これ以外に選べない)。 つまり40人ぶんの実行が全部、所有者ひとりの30枠を奪い合う。
学級の人数が枠を超えているので、余裕は「1実行あたりの短さ」でしか作れない。 守っている規則は3つ。
summary と daily は
メール別・クラス別に畳んだ索引をスクリプトキャッシュで共有する
(summaryIndex_ / top3_)。読むのは最初の1実行だけtryLock(0))、記録は10秒で諦めて端末に送り返す。
教室で先に壊れるのは記録ではなく「始められないこと」枠を超えた実行がどう失敗するかはクライアントからは見えないので、
失敗を握り潰さないことも同じくらい重要。起動は指数バックオフで取り直し、
問題列の到着待ちは15秒で打ち切る(common/index.html)。
すべての単元で同じ。単元ごとに別のスプレッドシートを持つ。
| シート | 書き手 | 内容 |
|---|---|---|
config |
教師 | 制限時間・閾値・教師メール |
roster |
教師 | email / 学年 / 組 / 番号 / 氏名 |
class_config |
教師 | クラスごとに公開するモード(mode_1…)と順次開放の解除(seq_off) |
log |
アプリ | 1試行1行。誤答と遅答だけ問題単位で圧縮して記録 |
daily |
アプリ | 当日ランキング(学級 × モード × 制限時間) |
summary |
アプリ | 累計と自己ベスト(児童 × モード × 制限時間) |
weak_child |
集計 | 児童 × 問題型 の平均想起時間 |
weak_class |
集計 | 問題型ごとの誤答数と、よくある誤答の一覧 |
coop |
アプリ | 協力モードの履歴(id / クラス / 図形 / seed / 組 / 名・番号 / 開始・終了 / 状態 / 合計) |
coop_log |
アプリ | 協力モードの提出イベント(session / t / email / correct / mode) |
coop_save |
教師 | 協力モードの保存データ(再生そのものの payload JSON) |
記録・分析の対象は、名簿に email と氏名の両方がある児童だけ。 名簿にないアカウント(名簿未登録の児童・氏名が空の行)はゲストとして 全モードを使えるが、トークンを発行しないので送信も通らず、シートには何も書かれない。 集計も同じ名簿条件で log を絞るので、名簿から氏名を消した児童の過去分は weak_child / mistakes からも消える(log の行自体は残る)。
1試行1行にした理由:問題単位で全部残すと40人×3回×5日で週18,000行になり、 1年で破綻する。正解は集計値で持ち、誤答と閾値超えの遅答だけを1セルに圧縮すれば 週600行に収まる。
主指標を所要msにした理由:既習事項の習熟を測るので、正答率は早期に天井に張り付き 児童間の差が消える。差が出るのは想起にかかる時間。
UNIT.types が分析の列を決める。九九なら「段」、長さなら「問題型」。
長さ版は加えてよくある誤答の実物を集計する。
1200m を「12km 0m」とする1000倍のずれのように、
誤りが型を持つ単元では、誤答の中身が指導の的を直接示す。
順位だけだと、上位3名以外には何も残らない。個人内評価を並置する。
| 何を測る | 記号 | 更新 | |
|---|---|---|---|
| 学級内 | 当日の学級順位1〜3位 | 🥇🥈🥉(絵文字) | 当日リセット |
| 個人内 | 自己ベストを塗り替えた回数 | ★n(ミントの文字、1〜99) | 累積・下がらない |
記号の種類(絵文字と文字)で見た目を分けてある。
到達バッジ(一定点数で段階が上がる)にしなかった理由: 閾値の適正値が学級ごとに違い、上位層はすぐ天井、下位層は永遠に最低段階になる。 それは学級内順位と同じ欠陥を、個人内評価の顔をして持ち込むことになる。 更新回数なら遅い子でも伸びれば必ず増える。
★は本番だけ加算する。練習で増やすと意味が薄れる。
練習は原則として記録しない。ただし 時間制限あり かつ ランダム出題のときだけ、本番とは別枠のハイスコアを残す。
log にも書かない(分析が濁るため)記録する練習(時間制限あり)は startSession を通す。 クライアントに
点数を自己申告させる経路を作らないため。practice: true を渡し、サーバーが再採点する。
記録しない練習(むせいげん)は nextPracticeItem で1問ずつ受け取る。
採点も記録もしないので startSession のようなトークン・キャッシュは不要。
それでも出題自体は必ずサーバーが作る。クライアントに出題ロジックを
持たせると docs/ADD_UNIT.md が明記する既知バグ(サーバーとの二重管理)を再発するため。
練習で範囲を絞る機能(practiceNarrow / genNarrow)は削除した。
以前この2つを UNIT に定義できる予約があったが、index.html 側の受け口
(段選択画面・絞り込み引数)が存在せず、機能していなかった。
復活させる場合は絞った練習を記録しない前提で、選択画面と
nextPracticeItem への引数から作り直すこと。
生ログは機械可読を優先した形なので、教師がそのまま読むには向かない。 以下を分離した。
mode=3 だけでなく
「わり算(9×9)」と文字でも分かる3.2秒)で表示する。
「遅い」の色は秒の固定閾値ではなく、同じ学年・同じ型の児童の分布と比べて付ける(次節)UNIT.fieldsByType があれば単位つきで表示する(例:”1/200” → “1km200m”)fieldsByType はオプション。答えが単一値でよい単元(九九など)は省略でき、
その場合は値をそのまま表示する。
単元が増えると入口が分からなくなる。ハブはリンク集に徹する。
IMPORTRANGE で参照する(三重管理を避ける)意図的に入れていないもの:児童の画面に出す進捗表示、達成バッジ、履歴。 ハブが重くなるほど「入口」としての速さが失われる。
Analysis.gs / analysis.html。要件は docs/ANALYSIS_REQUIREMENTS.md。
入口の速さを守るため、児童の経路(doGet → links、boot)には一切載せない。
nightlyAnalysis、1時台)。各単元の log の未処理の行だけを読み、
児童×単元×型×週の表 an_facts に足し込む(読んだ位置は an_sources の cursor)?page=analysis。開けるのは所有者と config.an_analysts だけ。
ハブの isTeacher_ はドメイン一致なので、単元横断の児童別データには使わないmeta シートに書き(教師画面を開いた時と setup の時だけ)、ハブはそれを読む。
ハブは別プロジェクトなので UNIT.types を直接は読めないtypeStat_ / splitCellItems_)はハブに写してある。共通ファイルに切り出すと
全単元の設置ファイルが増えるため。tests/analysis.test.cjs が同じ入力で Core と同じ結果になることを確かめるconfig の an_*ハブを学年(低・高など)ごとに分けて管理する場合、名簿の正本も学年ごとのハブに分かれる。
各単元の roster は縦積みで取り込む:
={IMPORTRANGE("低学年ハブのURL","roster!A:E");IMPORTRANGE("高学年ハブのURL","roster!A:E")}
縦積みでは各ソースのヘッダ行が途中に混ざる。名簿を読む処理は必ず
rosterRows_()(Core.gs)を経由する。@ を含まない行(ヘッダ・空行)はここで捨て、
呼び出し側が自分で判定しないこと(読み取り口を1つにしておけば、積み方が変わっても
単元は無関係でいられる)。
同じ児童が複数ハブに載ったとき:同じメールの行は1人に畳む。正本は先に現れた行。 ただし先の行が氏名なしで後の行が氏名ありなら後の行を使う (記録・分析の対象になる児童を消さない)。クラス別の人数も重複カウントしない。 同学年の違う児童(別ハブの3-1同士など)はそのまま併合される—— 実際に別の集団を同じクラス表記で載せる設計なら、クラス側で区別する。
同じ児童でも年度が違えば別の記録・集計対象(去年は3-1、今年は4-2)。 名簿は年度ごとに分けて残す:
roster … 現在年度のライブ名簿(IMPORTRANGE や手入力)。児童の利用判定もこれroster_2025 … 過去年度のアーカイブ年度替わりは自動:毎朝走る rolloverRoster トリガーが 4/1 にだけ動き、
roster の中身を roster_<前年度> へ「値」として退避し、roster を空に戻す。
A1 が IMPORTRANGE などの式なら式を戻すので、ハブ側が新年度に更新されれば
名簿はそのまま流れ替わる。roster_<前年度> が既にあれば何もしない(手動で
残しておいた年度名簿を上書きしない)。
ぶんせきの過去年度表示は roster_<年度> があればそこから名簿を読む。
無い年度は log の行が持つ「記録時点の学年・組・氏名」から名簿を組み立てるので、
アーカイブが無くても過去年度は正しいクラス所属で見られる
(ただしその年度に一度も記録の無い児童は組み立てられない)。
単元が増えると、宣言の欠落や gen の返り値のずれが入り込む。
validateUnit_() が宣言と実働の両方を点検する(エディタからは checkUnit()、
setup() の末尾でも走る。教師画面の読み込み時にも同じ結果が警告として出る)。
「注意:」で始まる行は設計上許容されるもの(digitCap の未宣言欄など)で、
残りは直すべきもの。テスト側では tests/contract.test.cjs が apps/ 配下の
全単元にこの検査を掛けるので、新しい単元は置くだけで契約テストに入る。
設置は5ファイルの手動貼り付けなので、「Core.gs だけ古い・index.html だけ新しい」
の組み合わせが起きる。Core.gs は応答に ENGINE_VER を載せ、画面側は自分の
WANT_VER と比べて、ちがっていれば画面に警告を出す。
WANT_VER は index.html・teacher.html それぞれに手書きしてある
(<?!= ENGINE_VER ?> で埋めると稼働中の Core.gs の値が入り、応答の版と
必ず一致して検知が死ぬ — 検知の対象が「貼ったファイル群の混在」だから、
比べる相手はファイル側に書かれた値でなくてはならない)。
片方にだけ依存を増やす変更(画面が Core の新しい応答を前提にする、またはその逆)では
ENGINE_VER と両画面の WANT_VER の3か所を一緒に上げる
(ずれたままにすると tests/cache.test.cjs が検知する)。
新しい単元を足す手順は docs/ADD_UNIT.md を見る。
Unit.gs を1枚書き、Core と index をコピーして貼るだけで動く。
単元を足したら node tests/contract.test.cjs(または setup() / checkUnit())で
宣言と出題の形を機械検査できる。
Core を変えるときは、単元固有の知識を Core に入れないこと。 「長さのときだけ」という分岐が Core に現れたら、それは Unit に置くべきもの。
名簿や設定など、シートの読み取り口を増やさないこと。
rosterRows_() のように1つの関数に寄せておくと、データの取り込み方が変わったとき
(例:名簿の縦積み)直す場所が1箇所で済む。
「遅い」は秒の固定閾値で決めない。同じ学年・同じ型の児童の分布と比べる(Core.gs の slowBand_ ほか)。
型ごとに打鍵数・手順の数が違うので、1つの秒数では型によって赤が出すぎたり出なかったりする。
| 段 | やり方 | 理由 | 根拠 |
|---|---|---|---|
| 1回ごとの時間 | 対数に直す | 反応時間は遅い側に長く裾を引く。平均が裾に引っぱられる | Ratcliff (1993):外れ値の処理として、観測を捨てない変換(対数・逆数)を推奨 |
| 児童の代表値 | 対数の平均=幾何平均 | ときどき大きく遅れた回(よそ見・数え直し)で代表値が動かない | 同上 |
| 学年・型の中心と広がり | 中央値と MAD×1.4826 | 平均・SD は数人の極端な値に引っぱられ、その極端な値自体を見落とす。MAD は崩壊点 0.5 | Leys et al. (2013) |
| 段階 | 頑健な z で ≤0/≤1/≤2/>2 | ×1.4826 で正規分布なら SD と同じ目盛りになる。偏差値=50−10z(速いほど高い) | 同上 |
| 1回の中の飛びぬけて遅い問題(slow_items) | 対数で 中央値+2.5×MAD 超 | 論文の既定値 | Leys et al. (2013) |
| 本人の中で遅い型(◆) | 本人の偏差値の中央より10以上低く、かつその型の偏差値の標準誤差の2倍を超える | 個人内の差は2つの値の誤差が両方乗り、見かけの凸凹が出やすい | McDermott, Fantuzzo & Glutting (1990) ほか、下位検査のプロフィール分析への批判 |
SLOW_MIN_KIDS_ = 10:比べる児童の最少人数。中央値・MAD が数人で決まらないようにした保守的な値SLOW_MIN_TRIES_ = 5:児童の代表値を使う最少回数。ゆらぎの判定(5回)にそろえたSELF_GAP_DV_ = 10:◆の差(偏差値10=1σ)