public/dev_tools.js

共通開発道具・バグ報告等。省略せず全文収録

1712行 · 102,814 bytes · SHA-256 c1ae188b528f8c7420d83e149ff6a0cd2a551d59d18e3d1b489a6c32d8339cf6

分割 1/10 分割 2/10 分割 3/10 分割 4/10 分割 5/10 分割 6/10 分割 7/10 分割 8/10 分割 9/10 分割 10/10
/* 開発者向け隠しツール(2026-08-07 K1指示)。
   ★リリース時はこの1行をfalseにするだけで全部止まる(K1指示: 隠し機能を消せるように)★
   呼び出し: 画面をトリプルタップ(3連打)して3回目を長押し→ツールメニュー
   ・px物差し: ドラッグで移動、右端の○で回転(左端が軸)。半透明・300px・10px刻み
   ・タップ座標: ONの間、タップした場所の教室内座標(390×632系)を表示
   ・スナップショット: いまの戦況/ゲーム状態をJSONでクリップボードへ(バグ報告用) */
window.HZG_DEV_TOOLS = true;

/* 計器の入切(2026-09-06 K1指示「規定は全部オフ」「計器を完全にオフのモードも作れ」)。
   毎コマ・毎回、入れ物を作ったりDOMを触る計器は**既定オフ**。
   出来事が起きた時だけ動く軽い物(起動・音の口・曲・画面が隠れた・コマ仕事・物理の重さ)は既定オン。
   HZG_METERS.全部切る=true にすると、軽い物も含めて**1つ残らず止まる**。
   ここは端末に保存する(🐛の「計器」から切り替え)。バグを追う時だけ点けて、終わったら消す。 */
window.HZG_METERS = (function(){
  var D = { 全部切る:false, 効果音:false, 床の物:false, 必殺技:false, 絵の大きさ:false,
            起動:true, 音の口:true, 曲:true, 画面:true, コマ仕事:true, 物理:true,
            /* ★スポット計器(そのバグ専用・直したら消す)。追っている間だけ点ける物なので既定オン */
            スポット:true };
  var v = {};
  for(var k in D) v[k]=D[k];
  try{ var j=JSON.parse(localStorage.getItem('hzg_meters_v1')||'null');
    if(j) for(var k2 in D) if(typeof j[k2]==='boolean') v[k2]=j[k2]; }catch(e){}
  v.既定 = D;
  v.on = function(name){ return !v.全部切る && !!v[name]; };
  v.set = function(name, b){ v[name]=!!b;
    try{ var o={}; for(var k3 in D) o[k3]=v[k3]; localStorage.setItem('hzg_meters_v1', JSON.stringify(o)); }catch(e){} };
  return v;
})();

(function(){
'use strict';
if(!window.HZG_DEV_TOOLS) return;

/* ---------- 共通: 教室シーンの座標系(本編・襲撃どちらも390×632の#scene) ---------- */
function findScene(){
  const m=document.getElementById('mount');
  if(m && m.shadowRoot){ const sc=m.shadowRoot.getElementById('scene'); if(sc) return sc; }
  return document.getElementById('scene');
}
function toSceneXY(cx,cy){
  const sc=findScene(); if(!sc) return null;
  const r=sc.getBoundingClientRect(); if(!r.width) return null;
  const k=r.width/390;
  return { x:Math.round((cx-r.left)/k), y:Math.round((cy-r.top)/k) };
}

/* ---------- ① px物差し ---------- */
let ruler=null, angle=0, px=60, py=200;
function buildRuler(){
  const r=document.createElement('div');
  r.id='hzgRuler';
  r.style.cssText='position:fixed; left:0; top:0; width:300px; height:44px; z-index:999999;'
    +'touch-action:none; -webkit-user-select:none; user-select:none; transform-origin:0 50%;'
    +'background:rgba(255,244,200,.42); border:1px solid rgba(120,90,20,.6); border-radius:4px;'
    +'box-shadow:0 2px 8px rgba(0,0,0,.25);';
  const ticks=document.createElement('div');
  ticks.style.cssText='position:absolute; inset:0; pointer-events:none;'
    +'background:'
    +'repeating-linear-gradient(90deg, rgba(60,40,10,.75) 0 1px, transparent 1px 10px),'
    +'repeating-linear-gradient(90deg, rgba(60,40,10,.9) 0 1px, transparent 1px 50px);'
    +'background-size:100% 8px, 100% 15px; background-repeat:no-repeat;';
  r.appendChild(ticks);
  for(let v=0; v<=300; v+=50){
    const lb=document.createElement('span');
    lb.textContent=v;
    lb.style.cssText='position:absolute; top:17px; left:'+(v-11)+'px; width:22px; text-align:center;'
      +'font:9px/1 -apple-system,sans-serif; color:rgba(60,40,10,.85); pointer-events:none;';
    r.appendChild(lb);
  }
  const deg=document.createElement('span');
  deg.id='hzgRulerDeg';
  deg.style.cssText='position:absolute; left:50%; bottom:2px; transform:translateX(-50%);'
    +'font:9px/1 -apple-system,sans-serif; color:rgba(60,40,10,.7); pointer-events:none;';
  r.appendChild(deg);
  const hd=document.createElement('div');
  hd.id='hzgRulerHandle';
  hd.textContent='↻';
  hd.style.cssText='position:absolute; right:-15px; top:50%; margin-top:-15px; width:30px; height:30px;'
    +'border-radius:50%; background:rgba(40,83,63,.85); color:#f7f0dd; font:16px/30px sans-serif;'
    +'text-align:center; border:1px solid rgba(255,255,255,.4); touch-action:none;';
  r.appendChild(hd);
  let drag=null;
  r.addEventListener('pointerdown', ev=>{
    if(ev.target===hd) return;
    ev.stopPropagation(); ev.preventDefault();
    drag={x0:ev.clientX, y0:ev.clientY, px0:px, py0:py};
    try{ r.setPointerCapture(ev.pointerId); }catch(e){}
  });
  r.addEventListener('pointermove', ev=>{
    if(!drag) return;
    px=drag.px0+(ev.clientX-drag.x0); py=drag.py0+(ev.clientY-drag.y0);
    placeRuler();
  });
  const dEnd=()=>{ drag=null; };
  r.addEventListener('pointerup', dEnd);
  r.addEventListener('pointercancel', dEnd);
  hd.addEventListener('pointerdown', ev=>{
    ev.stopPropagation(); ev.preventDefault();
    try{ hd.setPointerCapture(ev.pointerId); }catch(e){}
    const mv=e2=>{ angle=Math.atan2(e2.clientY-py, e2.clientX-px)*180/Math.PI; placeRuler(); };
    const up=()=>{ hd.removeEventListener('pointermove',mv); hd.removeEventListener('pointerup',up); hd.removeEventListener('pointercancel',up); };
    hd.addEventListener('pointermove',mv);
    hd.addEventListener('pointerup',up);
    hd.addEventListener('pointercancel',up);
  });
  document.body.appendChild(r);
  return r;
}
function placeRuler(){
  ruler.style.transform='translate('+px+'px,'+(py-22)+'px) rotate('+angle+'deg)';
  const d=document.getElementById('hzgRulerDeg');
  if(d){ const a=((angle%360)+360)%360; d.textContent=Math.round(a)+'°'; }
}
function toggleRuler(x,y){
  if(ruler){ ruler.remove(); ruler=null; return; }
  px=Math.min(Math.max(10,x), innerWidth-60); py=y; angle=0;
  ruler=buildRuler(); placeRuler();
}

/* ---------- ② タップ座標インスペクタ ---------- */
let inspectOn=false;
document.addEventListener('pointerdown', ev=>{
  if(!inspectOn || !ev.isPrimary) return;
  if(ev.target && ev.target.closest && ev.target.closest('#hzgDevMenu,#hzgRuler')) return;
  const p=toSceneXY(ev.clientX, ev.clientY);
  const lb=document.createElement('div');
  lb.textContent=p ? (p.x+', '+p.y) : (Math.round(ev.clientX)+', '+Math.round(ev.clientY)+' (画面px)');
  lb.style.cssText='position:fixed; z-index:999998; pointer-events:none;'
    +'left:'+(ev.clientX+10)+'px; top:'+(ev.clientY-30)+'px;'
    +'background:rgba(12,20,16,.9); color:#ffe184; font:bold 12px/1 -apple-system,sans-serif;'
    +'padding:5px 8px; border-radius:7px; border:1px solid rgba(255,214,106,.5); white-space:nowrap;';
  document.body.appendChild(lb);
  const a=lb.animate([{opacity:1},{opacity:1,offset:.7},{opacity:0}],{duration:1400});
  a.finished.then(()=>lb.remove()).catch(()=>lb.remove());
}, true);

/* ---------- ③ 不具合スナップショット ---------- */
/* DOM・関数・循環参照を安全に落とすstringify(フィールドを列挙しないので実装が変わっても壊れない) */
function safeJson(obj){
  /* 「同じ物を2度見たら循環」だと、共有しているだけの値(席の座標など)まで潰れる。
     今いる枝をたどって、本当に自分へ戻ってきた時だけ循環とする(2026-08-18) */
  const stack=[];
  return JSON.stringify(obj, function(k,v){
    if(typeof v==='function') return undefined;
    if(v && typeof v==='object'){
      if(typeof Element!=='undefined' && v instanceof Element) return undefined;
      if(typeof Animation!=='undefined' && v instanceof Animation) return undefined;
      while(stack.length && stack[stack.length-1]!==this) stack.pop();
      if(stack.indexOf(v)>=0) return '[循環]';
      stack.push(v);
    }
    if(typeof v==='number') return Math.round(v*100)/100;
    return v;
  });
}
/* gameの丸ごと書き出しをやめ、開始時から積み上がる巨大データは要約に置き換える(2026-08-10 K1指示)。
   mobPool=140人の名簿+フレーバー全文/blood=血痕1滴ごとの座標/corpses=死体のフレーバー/
   既読セリフ6種の番号の束——調査で使わないのに全体の8割を占めていた */
function slimDaily(g){
  const d={};
  for(const k in g){
    if(k==='mobPool'){ const p=g[k]||{}; d.mobPool='残り 2年'+((p.y2||[]).length)+'人・他学年'+((p.other||[]).length)+'人'; continue; }
    if(k==='blood'){ let n=(g[k]||[]).length;
      try{ if(typeof battleHandle!=='undefined' && battleHandle && battleHandle.bloodRefs) n=battleHandle.bloodRefs().length; }catch(e){}
      d.blood='血痕'+n+'滴'; continue; }   // 段2: 正本はエンジン帳面
    if(k==='corpses'){ d.corpses=(g[k]||[]).map(c=>({x:c.x, y:c.y, label:c.label, fall:c.fall})); continue; }
    if(/Seen$/.test(k)){ const v=g[k]; d[k]='既読'+((v&&v.size!=null)?v.size:(v?Object.keys(v).length:0))+'件'; continue; }
    d[k]=g[k];
  }
  return d;
}
function takeSnapshot(){
  const out={ at:new Date().toISOString(), ver:window.HZG_BUILD||'?', url:location.pathname+location.search };
  // 職員室凍結の遠隔調査(2026-08-13): 部屋の組み立て結果と端末に残る保存キーも吐く
  try{ if(typeof placeHandle!=='undefined' && placeHandle && placeHandle.roomInfo) out.roomInfo=placeHandle.roomInfo(); }catch(e){ out.roomInfo='err:'+e.message; }
  try{ out.lsKeys=Object.keys(localStorage).map(k=>k+':'+(localStorage.getItem(k)||'').length); }catch(e){}
  try{ if(typeof window.HZG_SNAPSHOT==='function') out.raid=window.HZG_SNAPSHOT(); }catch(e){ out.raidErr=String(e); }
  try{ if(typeof game!=='undefined') out.daily=slimDaily(game); }catch(e){}
  let txt;
  try{ txt=safeJson(out); }catch(e){ txt='{"err":"'+String(e)+'"}'; }
  const kb=Math.round(txt.length/102.4)/10;
  const done=okFlag=>toast(okFlag ? 'スナップショットをコピーした ('+kb+'KB)' : 'コピー失敗→手動コピー画面を出す');
  if(navigator.clipboard && navigator.clipboard.writeText){
    navigator.clipboard.writeText(txt).then(()=>done(true)).catch(()=>{ done(false); manualCopy(txt); });
  } else { done(false); manualCopy(txt); }
}
function manualCopy(txt){
  const w=document.createElement('div');
  w.style.cssText='position:fixed; inset:8% 5%; z-index:999999; background:rgba(12,20,16,.97);'
    +'border:1px solid #4d7a5f; border-radius:12px; padding:10px; display:flex; flex-direction:column; gap:8px;';
  const ta=document.createElement('textarea');
  ta.value=txt; ta.readOnly=true;
  ta.style.cssText='flex:1; background:#0b130f; color:#cfe0d5; font:10px/1.4 monospace; border:0; border-radius:8px; padding:8px;';
  const bt=document.createElement('button');
  bt.textContent='閉じる';
  bt.style.cssText='padding:10px; border:0; border-radius:9px; background:#28533f; color:#f7f0dd; font-weight:bold;';
  bt.addEventListener('click',()=>w.remove());
  w.appendChild(ta); w.appendChild(bt);
  document.body.appendChild(w);
  ta.focus(); ta.select();
}
function toast(msg){
  const t=document.createElement('div');
  t.textContent=msg;
  t.style.cssText='position:fixed; left:50%; top:14%; transform:translateX(-50%); z-index:999999;'
    +'background:rgba(12,20,16,.92); color:#f7f0dd; font:bold 12px/1.4 -apple-system,sans-serif;'
    +'padding:9px 14px; border-radius:9px; border:1px solid #4d7a5f; pointer-events:none; white-space:nowrap;';
  document.body.appendChild(t);
  const a=t.animate([{opacity:0},{opacity:1,offset:.1},{opacity:1,offset:.8},{opacity:0}],{duration:1900});
  a.finished.then(()=>t.remove()).catch(()=>t.remove());
}

/* ---------- メモ帳(2026-08-07 K1指示) ----------
   開いたら毎回さっきの続き(書きかけは自動保存)。保存=1行目を見出しに一覧へ入れて新規に。
   削除=書きかけを捨てて新規に。本文コピーあり。保存済みは見出しタップで続きを書ける */
const MEMO_DRAFT='hzg_memo_draft_v1', MEMO_LIST='hzg_memo_list_v1';
let memoWin=null;
function memoList(){ try{ return JSON.parse(localStorage.getItem(MEMO_LIST)||'[]')||[]; }catch(e){ return []; } }
function openMemo(){
  if(memoWin){ memoWin.remove(); memoWin=null; }
  const w=document.createElement('div');
  memoWin=w;
  w.style.cssText='position:fixed; inset:6% 4% 8%; z-index:999999; display:flex; flex-direction:column; gap:7px;'
    +'background:rgba(12,20,16,.97); border:1px solid #4d7a5f; border-radius:13px; padding:10px;';
  const ta=document.createElement('textarea');
  ta.placeholder='メモ(書きかけは勝手に残る)';
  ta.value=localStorage.getItem(MEMO_DRAFT)||'';
  ta.style.cssText='flex:1; min-height:0; background:#0b130f; color:#eee6d4; font:13px/1.6 -apple-system,sans-serif;'
    +'border:1px solid #2c4638; border-radius:9px; padding:9px; resize:none; -webkit-user-select:text; user-select:text;';
  ta.addEventListener('input', ()=>localStorage.setItem(MEMO_DRAFT, ta.value));
  const row=document.createElement('div');
  row.style.cssText='display:flex; gap:6px;';
  const mkB=(label,bg,fn)=>{
    const b=document.createElement('button');
    b.textContent=label;
    b.style.cssText='flex:1; padding:11px 4px; border:0; border-radius:9px; background:'+bg+';'
      +'color:#f7f0dd; font:bold 12px/1 -apple-system,sans-serif;';
    b.addEventListener('click', ev=>{ ev.stopPropagation(); fn(); });
    row.appendChild(b);
  };
  const list=document.createElement('div');
  list.style.cssText='flex:0 0 auto; max-height:32%; overflow-y:auto; display:flex; flex-direction:column; gap:4px;';
  const renderList=()=>{
    list.innerHTML='';
    for(const [i,m] of memoList().entries()){
      const r=document.createElement('div');
      r.style.cssText='display:flex; align-items:center; gap:6px; background:#1a2a21; border:1px solid #2c4638;'
        +'border-radius:8px; padding:8px 9px;';
      const t=document.createElement('span');
      t.textContent=m.t||'(無題)';
      t.style.cssText='flex:1; font:bold 11.5px/1.3 -apple-system,sans-serif; color:#cfe0d5;'
        +'white-space:nowrap; overflow:hidden; text-overflow:ellipsis;';
      t.addEventListener('click', ()=>{   // 見出しタップ=そのメモの続きを書く(一覧からは外す)
        const arr=memoList(); const [mm]=arr.splice(i,1);
        localStorage.setItem(MEMO_LIST, JSON.stringify(arr));
        ta.value=mm.body; localStorage.setItem(MEMO_DRAFT, mm.body);
        renderList();
      });
      const x=document.createElement('button');
      x.textContent='✕';
      x.style.cssText='flex:0 0 auto; width:30px; height:30px; border:0; border-radius:7px;'
        +'background:#7a4040; color:#f7f0dd; font:bold 12px/1 sans-serif;';
      x.addEventListener('click', ()=>{
        const arr=memoList(); arr.splice(i,1);
        localStorage.setItem(MEMO_LIST, JSON.stringify(arr));
        renderList();
      });
      r.appendChild(t); r.appendChild(x); list.appendChild(r);
    }
  };
  mkB('保存','#28533f',()=>{
    const v=ta.value.trim();
    if(!v){ toast('空のメモは保存しない'); return; }
    const arr=memoList();
    arr.unshift({ t:v.split('\n')[0].slice(0,40), body:ta.value, at:Date.now() });
    localStorage.setItem(MEMO_LIST, JSON.stringify(arr));
    ta.value=''; localStorage.setItem(MEMO_DRAFT,'');
    renderList(); toast('保存した(新規メモに)');
  });
  mkB('本文コピー','#3f6f56',()=>{
    const v=ta.value;
    if(navigator.clipboard && navigator.clipboard.writeText){
      navigator.clipboard.writeText(v).then(()=>toast('本文をコピーした')).catch(()=>manualCopy(v));
    } else manualCopy(v);
  });
  mkB('削除','#7a4040',()=>{ ta.value=''; localStorage.setItem(MEMO_DRAFT,''); toast('消して新規メモに'); });
  mkB('閉じる','#5d5040',()=>{ w.remove(); memoWin=null; });
  w.appendChild(ta); w.appendChild(row); w.appendChild(list);
  w.addEventListener('pointerdown', ev=>ev.stopPropagation());
  document.body.appendChild(w);
  renderList();
}

/* ---------- ④ UIレイアウト編集モード(2026-08-07 K1指示) ----------
   対象のUI部品をドラッグで動かし、動かした差分だけをJSONで吐く。
   差分はlocalStorage(hzg_uiedit_v1)にも残して次回起動時も適用——
   K1がJSONをくれたらコードへ焼き込み、リセットで消す運用 */
const UIED_KEY='hzg_uiedit_v1';
/* 【2026-09-02 K1指摘の根治】K1「デプロイごとのUIの差分をJSONで吐かないといけないところを、
   UIレイアウト編集を開くごとの差分にしてないか」——そのとおりだった。
   端末に残った調整値はコードの値の上に重ねて掛かるので、こちらが数値を焼き込むと
   「焼いた値+端末の値」の二重になり、次に出す差分は**焼き込み後の位置からの差**になっていた。
   これでは何度やっても合わない。
   直し方: 調整値に**その時の版番号**を付けて持ち、版が変わったら丸ごと捨てる。
   つまり調整値は必ず「今デプロイされているコードの値」からの差になる。
   部品ごとに手で UIED_BAKED_IDS へ足していく運用も要らなくなる */
function uiedVer(){
  try{
    const js=document.querySelector('script[src*="raid_battle.js"]');
    return String((js && js.getAttribute('src')) || '').split('?')[1] || '?';
  }catch(e){ return '?'; }
}
/* 焼き込みの印(2026-08-26 K1報告の根治): K1が調整した値をコードへ焼いた時は、この文字列を変える。
   端末に残っている調整値は、焼いた値と二重に掛かって「調整が無かったことにされた」ように見える——
   実際に下段ラベルで発生した(焼き込みで11px下げ+端末の11pxが1.2秒後の再適用で乗り、22px下がった)。
   印が変わっていたら、起動時に端末の調整値を自動で捨てる(=リセットを押したのと同じ)。
   K1に「リセットを押してください」と頼まなくて済むようにするための仕掛け */
const UIED_BAKED='20260901v';
const UIED_BAKED_KEY='hzg_uiedit_baked';
/* armoryRowは armory の中にあるので、**必ずarmoryより先**に並べる——
   先に書いた方が掴める(2026-09-02) */
const UIED_IDS=['armoryRow','spPanel','wazaBtn','ctrlFab','pauseTag','dmgLbl','itemBtn','raidTestClose','armory','readyBtn','sleepBtn','goPop','hdr',
  'rationBar','amTidy','setBtn',
  'partyAsk',   // 「誰が行きますか」バー(2026-08-21 K1指示)
  /* 下段ラベル(2026-08-26 K1指示「素材に圧迫感がある」)。板と中身を別々に動かせる:
     tabBar=ドックの板そのもの / tabRow=アイコンと文字の5個ぶんまとめて。
     大きさのつまみ(%)で縮めれば圧迫感を弱められる */
  'tabBar','tabRow',
  /* 所持金と探索ライブ中継の小窓(2026-09-01 K1指示)。
     小窓は**探索に出している間しか出ない**ので、動かす時は隊を出してから編集モードに入る */
  'moneyHud','lfChip',
  /* インベントリ(2026-09-02 K1指示)。**中身と縁を別々に動かす**:
     ・armoryRow = 中のアイテム欄(66pxのマスの格子)そのもの
     ・armory    = 外の縁とタブボタンが合体した板ぜんぶ
     armoryRowをarmoryより先に並べる——重なっているので、先に書いた方が掴める。
     縁(armory)を大きくしても中のマスは大きくならない(下のuiedRowSyncが打ち消す)ので、
     「縁だけ広げて、中身は中身で動かす」ができる */
  ];
/* 【2026-09-02 K1指摘の根治その2】つまみは開くと必ず100%から始まり、
   触った瞬間にCSSへ焼いてある値(例 #armory の scale:.86)を**上書き**していた。
   つまり編集を始めた瞬間に部品が跳び、K1は「今どこにいるか」を知らないまま調整していた。
   位置も同じで、CSSに translate が焼いてある部品は掴んだ瞬間0へ戻っていた。
   直し方: 初めて掴んだ時に**今そう見えている値**(computed)を読んで、そこから始める。
   これでつまみの数字は常に「今の姿」を指し、出てくる差分は
   **そのままCSSへ書ける最終の値**になる(こちらで掛け算しない=ずれる余地が消える) */
function uiedInit(el){
  const o={dx:0, dy:0};
  try{
    const cs=getComputedStyle(el);
    const t=cs.translate;
    if(t && t!=='none'){ const p=t.split(/\s+/); o.dx=parseFloat(p[0])||0; o.dy=parseFloat(p[1]||'0')||0; }
    const sc=cs.scale;
    if(sc && sc!=='none'){ const v=parseFloat(sc); if(v>0) o.s=v; }
  }catch(e){}
  return o;
}
/* 縁(armory)の拡大を中のアイテム欄に効かせない。
   armoryRow自身のずらし・大きさは、その上に足す */
function uiedRowSync(){
  const el=uiedEl('armoryRow'); if(!el) return;
  const S=(uiedOff.armory && uiedOff.armory.s) || 1;
  const r=uiedOff.armoryRow || {};
  const rs=(r.s!=null? r.s : 1);
  el.style.scale=String(rs/S);
  el.style.translate=((r.dx||0)/S).toFixed(1)+'px '+((r.dy||0)/S).toFixed(1)+'px';
}
let uiedOn=false, uiedOff={}, uiedBar=null, uiedDrag=null, uiedLast=null, uiedSzInput=null, uiedSzLabel=null;
try{
  const raw=JSON.parse(localStorage.getItem(UIED_KEY)||'null');
  if(raw && raw.__ver!==undefined){
    if(raw.__ver===uiedVer()){ uiedOff=raw.off||{}; }
    else { console.log('[UI編集] 版が変わったので端末の調整値を捨てた:', raw.__ver, '→', uiedVer());
           localStorage.removeItem(UIED_KEY); }
  } else if(raw){
    /* 版の印が無い古い形は、いつのコードに対する差か分からないので捨てる */
    console.log('[UI編集] 版の印が無い古い調整値を捨てた');
    localStorage.removeItem(UIED_KEY);
  }
}catch(e){}
/* 保存はいつも版の印つき。読む時に版が違えば捨てられる */
function uiedSave(){
  try{ localStorage.setItem(UIED_KEY, JSON.stringify({__ver:uiedVer(), off:uiedOff})); }catch(e){}
}
/* 焼き込み済みの部品は、起動のたびに端末の調整値を必ず捨てる(2026-08-26 K1報告の根治)。
   「印が変わった時だけ捨てる」方式は実機で効かなかった——報告のJSONで、焼き込みの上に
   端末の値(dy26・1.06倍・歯車+143,+41)が乗ったままなのが数字で確定した
   (下段ラベルの実測 高さ98=93×1.06 / 上端715 / 歯車の右端512=343+143+26)。
   条件付きだと、印を書いた後に編集し直された値が次の起動で復活して二度と消えない。
   ここに載っている部品は「コードが正本」。編集は開いている間だけ効き、読み込み直すと
   必ずコードの値へ戻る——二重掛けが構造的に起きなくなる。
   K1から新しい数値をもらったらコードへ焼く、という運用はそのまま */
const UIED_BAKED_IDS=['tabBar','setBtn','moneyHud','lfChip'];   // 所持金と中継の小窓も焼いた(2026-09-01)
try{
  let drop=false;
  for(const id of UIED_BAKED_IDS) if(uiedOff[id]){ delete uiedOff[id]; drop=true; }
  if(drop){
    console.log('[UI編集] 焼き込み済みの部品なので端末の調整値を捨てた:', UIED_BAKED_IDS.join(','));
    if(Object.keys(uiedOff).length) uiedSave();
    else localStorage.removeItem(UIED_KEY);
  }
  localStorage.setItem(UIED_BAKED_KEY, UIED_BAKED);
}catch(e){}
/* Shadow DOMの中も総当たりで探す(2026-08-07 K1指摘: 肝心のSPバーが掴めない)。
   本編では襲撃エンジンのホストが#mountではないので、全要素のshadowRootを潜る */
function uiedFind(id){
  const direct=document.getElementById(id);
  if(direct) return direct;
  const stack=[document];
  while(stack.length){
    const root=stack.pop();
    const hit=root.getElementById ? root.getElementById(id) : root.querySelector('#'+id);
    if(hit) return hit;
    for(const el of root.querySelectorAll('*')) if(el.shadowRoot) stack.push(el.shadowRoot);
  }
  return null;
}
const uiedCache={};   // 深い探索は重いので見つけた要素は覚えておく
function uiedEl(id){
  const c=uiedCache[id];
  if(c && c.isConnected) return c;
  const el=uiedFind(id);
  if(el) uiedCache[id]=el;
  return el;
}
function uiedApply(){
  for(const id in uiedOff){
    const el=uiedEl(id); if(!el) continue;
    const o=uiedOff[id];
    if(id==='armoryRow') continue;   // 中のアイテム欄は下でまとめて合成する
    el.style.translate=(o.dx||0)+'px '+(o.dy||0)+'px';
    el.style.scale=(o.s!=null && o.s!==1) ? String(o.s) : '';
  }
}
/* 画面の中央に十字のガイドを出し、近づいたら吸い付く(2026-08-07 K1指示。
   まっすぐ縦・横に動かしたい時に手が震えても中心を外さないため) */
const UIED_SNAP=10;   // この距離まで近づいたら吸着(px)
let uiedCross=null;
function uiedCrossShow(){
  if(uiedCross) return;
  uiedCross=document.createElement('div');
  uiedCross.id='hzgUiedCross';
  uiedCross.style.cssText='position:fixed; inset:0; z-index:999998; pointer-events:none;';
  uiedCross.innerHTML='<i class="v"></i><i class="h"></i>';
  const st=document.createElement('style');
  st.textContent='#hzgUiedCross i{position:absolute;background:rgba(120,200,255,.5)}'
    +'#hzgUiedCross .v{left:50%;top:0;bottom:0;width:1px;margin-left:-.5px}'
    +'#hzgUiedCross .h{top:50%;left:0;right:0;height:1px;margin-top:-.5px}'
    +'#hzgUiedCross i.hot{background:rgba(255,214,106,.95);box-shadow:0 0 6px rgba(255,214,106,.8)}';
  uiedCross.appendChild(st);
  document.body.appendChild(uiedCross);
}
function uiedCrossHide(){ if(uiedCross){ uiedCross.remove(); uiedCross=null; } }
/* 動かしている部品の中心が画面の中心線に近ければ、その軸を中心へ吸わせる */
function uiedSnap(el, dx, dy){
  if(!uiedCross) return {dx, dy};
  const r=el.getBoundingClientRect();
  const cx=r.left+r.width/2, cy=r.top+r.height/2;
  const tx=window.innerWidth/2, ty=window.innerHeight/2;
  let sx=false, sy=false;
  if(Math.abs(cx-tx)<UIED_SNAP){ dx+=tx-cx; sx=true; }
  if(Math.abs(cy-ty)<UIED_SNAP){ dy+=ty-cy; sy=true; }
  uiedCross.querySelector('.v').classList.toggle('hot', sx);
  uiedCross.querySelector('.h').classList.toggle('hot', sy);
  return {dx, dy};
}
/* 保存済みの差分は起動後もずっと当て続ける(襲撃の部品は後から生まれるため) */
if(Object.keys(uiedOff).length) setInterval(uiedApply, 1200);
function uiedMark(on){
  for(const id of UIED_IDS){
    const el=uiedEl(id); if(!el) continue;
    el.style.outline=on?'2px dashed rgba(120,200,255,.9)':'';
    el.style.outlineOffset=on?'2px':'';
    /* 指を通す(2026-09-01 K1報告「所持金が動かせない。青い枠には囲まれてるのに」)。
       #moneyHud のように pointer-events:none の飾りは、枠は描けても**指が当たらない**ので
       掴めなかった。編集中だけ受け取る側へ回し、終わったら元の指定へ戻す
       (空文字にすればCSSの指定が戻る)。他の部品にも同じ物が増えても勝手に効く */
    el.style.pointerEvents=on?'auto':'';
  }
}
function uiedStart(){
  uiedOn=true;
  uiedApply(); uiedMark(true); uiedCrossShow();
  uiedBar=document.createElement('div');
  uiedBar.id='hzgUiedBar';
  uiedBar.style.cssText='position:fixed; left:50%; top:calc(6px + env(safe-area-inset-top)); transform:translateX(-50%);'
    +'z-index:999999; display:flex; gap:6px; align-items:center; padding:6px 8px;'
    +'background:rgba(12,20,16,.95); border:1px solid #4d7a5f; border-radius:10px;'
    +'-webkit-user-select:none; user-select:none;';
  const tag=document.createElement('span');
  tag.textContent='🧲UI編集中';
  tag.style.cssText='font:bold 11px/1 -apple-system,sans-serif; color:#ffe184; padding:0 2px;';
  uiedBar.appendChild(tag);
  const mkB=(label,fn)=>{
    const b=document.createElement('button');
    b.textContent=label;
    b.style.cssText='padding:8px 10px; border:0; border-radius:8px; background:#28533f; color:#f7f0dd; font:bold 11px/1 -apple-system,sans-serif;';
    b.addEventListener('click', ev=>{ ev.stopPropagation(); fn(); });
    uiedBar.appendChild(b);
  };
  // 選んだ部品の大きさ(2026-08-07 K1指示)。最後に触った部品に効く
  const sz=document.createElement('input');
  sz.type='range'; sz.min='50'; sz.max='200'; sz.step='2'; sz.value='100';
  sz.style.cssText='width:88px;';
  const szl=document.createElement('span');
  szl.textContent='100%';
  szl.style.cssText='font:bold 10px/1 -apple-system,sans-serif; color:#ffd76a; min-width:32px; text-align:right;';
  sz.addEventListener('input', ()=>{
    const id=uiedLast; szl.textContent=sz.value+'%';
    if(!id) return;
    const o=uiedOff[id]||(uiedOff[id]=uiedInit(uiedEl(id)));
    o.s=+sz.value/100;
    const el=uiedEl(id);
    if(id==='armoryRow' || id==='armory') uiedRowSync();
    if(id!=='armoryRow' && el) el.style.scale=String(o.s);
    uiedSave();
  });
  uiedSzInput=sz; uiedSzLabel=szl;
  uiedBar.appendChild(sz); uiedBar.appendChild(szl);
  mkB('差分コピー', ()=>{
    const out={};
    for(const id in uiedOff){
      const o=uiedOff[id]; if(!o) continue;
      /* 実際に要素へ当たっている値を出す。入れ子(armoryRowはarmoryの中)の割り算も
         済んだ後の値なので、**そのままCSSへ書ける**。こちらで計算し直さない */
      const el0=uiedEl(id);
      if(el0){
        const t0=el0.style.translate, s0=el0.style.scale;
        const e0={};
        if(t0){ const p=t0.split(/\s+/); const a0=parseFloat(p[0])||0, b0=parseFloat(p[1]||'0')||0;
                if(a0||b0){ e0.translate=Math.round(a0)+'px '+Math.round(b0)+'px'; } }
        if(s0 && parseFloat(s0)!==1) e0.scale=Math.round(parseFloat(s0)*1000)/1000;
        /* K1指示(2026-09-02)「10px上に、ではなく絶対値でないと怖い」。
           画面上の最終的な位置と大きさもそのまま出す。
           焼き込んだ後、こちらで同じ数字になるか測って確かめられる */
        try{
          const r0=el0.getBoundingClientRect();
          e0.実際の位置=Math.round(r0.left)+','+Math.round(r0.top);
          e0.実際の大きさ=Math.round(r0.width)+'x'+Math.round(r0.height);
        }catch(e2){}
        if(Object.keys(e0).length) out[id]=e0;
      }
      const e={};
      if(o.dx) e.dx=Math.round(o.dx);
      if(o.dy) e.dy=Math.round(o.dy);
      if(o.s!=null && o.s!==1) e.scale=Math.round(o.s*100)/100;
      if(Object.keys(e).length) out[id]=e;
    }
    const txt=JSON.stringify(out);
    if(navigator.clipboard && navigator.clipboard.writeText){
      navigator.clipboard.writeText(txt).then(()=>toast('差分をコピーした '+txt.slice(0,60))).catch(()=>manualCopy(txt));
    } else manualCopy(txt);
  });
  mkB('リセット', ()=>{
    for(const id in uiedOff){ const el=uiedEl(id); if(el){ el.style.translate=''; el.style.scale=''; } }
    uiedLast=null; if(uiedSzInput){ uiedSzInput.value='100'; uiedSzLabel.textContent='100%'; }
    uiedOff={}; localStorage.removeItem(UIED_KEY);
    toast('UI差分をリセットした');
  });
  mkB('終了', uiedStop);
  uiedBar.title='部品をドラッグで移動 / つまみで大きさ / 中央の十字に吸着';
  uiedBar.addEventListener('pointerdown', ev=>ev.stopPropagation());
  document.body.appendChild(uiedBar);
  toast('点線の部品をドラッグで動かせる');
}
function uiedStop(){
  uiedOn=false; uiedMark(false); uiedCrossHide();
  if(uiedBar){ uiedBar.remove(); uiedBar=null; }
}
/* 編集中はdocumentのキャプチャで先取りして、ゲーム側のタップ処理へ渡さない */
document.addEventListener('pointerdown', ev=>{
  if(!uiedOn) return;
  const t=(ev.composedPath && ev.composedPath()[0]) || ev.target;
  if(uiedBar && (t===uiedBar || uiedBar.contains(t))) return;
  for(const id of UIED_IDS){
    const el=uiedEl(id);
    if(el && (t===el || el.contains(t))){
      ev.stopPropagation(); ev.preventDefault();
      const o=uiedOff[id]||(uiedOff[id]=uiedInit(el));   // 今そう見えている値から始める(2026-09-02)
      uiedLast=id;
      if(uiedSzInput){ const v=Math.round((o.s!=null?o.s:1)*100); uiedSzInput.value=String(v); uiedSzLabel.textContent=v+'%'; }
      uiedDrag={id, el, x0:ev.clientX, y0:ev.clientY, dx0:o.dx||0, dy0:o.dy||0};
      return;
    }
  }
}, true);
document.addEventListener('pointermove', ev=>{
  if(!uiedOn || !uiedDrag) return;
  ev.stopPropagation(); ev.preventDefault();
  const o=uiedOff[uiedDrag.id];
  o.dx=uiedDrag.dx0+(ev.clientX-uiedDrag.x0);
  o.dy=uiedDrag.dy0+(ev.clientY-uiedDrag.y0);
  if(uiedDrag.id==='armoryRow') uiedRowSync();
  else uiedDrag.el.style.translate=o.dx+'px '+o.dy+'px';
  const sn=uiedSnap(uiedDrag.el, o.dx, o.dy);   // 画面中央の十字に吸わせる
  if(sn.dx!==o.dx || sn.dy!==o.dy){
    o.dx=sn.dx; o.dy=sn.dy;
    if(uiedDrag.id==='armoryRow') uiedRowSync();
    else uiedDrag.el.style.translate=o.dx+'px '+o.dy+'px';
  }
}, true);
document.addEventListener('pointerup', ev=>{
  if(!uiedOn || !uiedDrag) return;
  ev.stopPropagation();
  uiedDrag=null;
  uiedSave();
}, true);

/* ---------- ツールメニュー(ジェスチャで開く) ---------- */
let menu=null;
function closeMenu(){ if(menu){ menu.remove(); menu=null; } }
function openMenu(x,y){
  closeMenu();
  menu=document.createElement('div');
  menu.id='hzgDevMenu';
  const mx=Math.min(Math.max(8,x-70), innerWidth-160);
  const my=Math.min(Math.max(8,y-40), innerHeight-190);
  menu.style.cssText='position:fixed; left:'+mx+'px; top:'+my+'px; z-index:999999; width:152px;'
    +'background:rgba(12,20,16,.95); border:1px solid #4d7a5f; border-radius:11px; padding:7px;'
    +'display:flex; flex-direction:column; gap:6px; -webkit-user-select:none; user-select:none;';
  const mk=(label,fn)=>{
    const b=document.createElement('button');
    b.textContent=label;
    b.style.cssText='padding:11px 8px; border:0; border-radius:8px; background:#28533f; color:#f7f0dd;'
      +'font:bold 12px/1 -apple-system,sans-serif; text-align:left;';
    b.addEventListener('click', ev=>{ ev.stopPropagation(); closeMenu(); fn(); });
    menu.appendChild(b);
  };
  mk('📏 px物差し'+(ruler?'(片づける)':''), ()=>toggleRuler(x,y));
  mk('🎯 タップ座標 '+(inspectOn?'ON→OFF':'OFF→ON'), ()=>{ inspectOn=!inspectOn; toast('タップ座標: '+(inspectOn?'ON(タップで教室内座標)':'OFF')); });
  mk('📋 状況スナップショット', takeSnapshot);
  mk('🧲 UIレイアウト編集'+(uiedOn?'(終了)':''), ()=>{ uiedOn ? uiedStop() : uiedStart(); });
  mk('📝 メモ帳', openMemo);
  menu.addEventListener('pointerdown', ev=>ev.stopPropagation());
  document.body.appendChild(menu);
  setTimeout(()=>{
    const away=ev=>{ if(menu && !(ev.target.closest && ev.target.closest('#hzgDevMenu'))){ closeMenu(); document.removeEventListener('pointerdown', away, true); } };
    document.addEventListener('pointerdown', away, true);
  },0);
}

/* 呼び出しジェスチャ: 500ms以内・45px以内の3連打+3打目を550ms長押し */
let taps=[], lp=null;
document.addEventListener('pointerdown', ev=>{
  if(!ev.isPrimary) return;
  if(ev.target && ev.target.closest && ev.target.closest('#hzgDevMenu,#hzgRuler')) return;
  const now=performance.now();
  taps=taps.filter(t=>now-t.t<500 && Math.hypot(t.x-ev.clientX,t.y-ev.clientY)<45);
  taps.push({x:ev.clientX, y:ev.clientY, t:now});
  if(lp){ clearTimeout(lp.t); lp=null; }
  if(taps.length>=3){
    const x=ev.clientX, y=ev.clientY;
    lp={x, y, t:setTimeout(()=>{ lp=null; taps=[]; openMenu(x,y); }, 550)};
  }
}, true);
const lpCancel=ev=>{ if(lp && (ev.type!=='pointermove' || Math.hypot(ev.clientX-lp.x,ev.clientY-lp.y)>12)){ clearTimeout(lp.t); lp=null; } };
document.addEventListener('pointerup', lpCancel, true);
document.addEventListener('pointermove', lpCancel, true);
document.addEventListener('pointercancel', lpCancel, true);

/* ---------- ④ メモリ計(2026-08-15 K1指示: 既定ON・設定でOFF) ----------
   iOSのSafariは本物のメモリ量を読むAPIが無いので、リーク検知に効く代理指標を出す:
   ・音声=展開済み音声バッファの推定MB(戦闘SFX共有キャッシュ+本編のSE/BGM)
   ・DOM=画面部品の総数(戦闘のShadow DOM内も含む。捨て忘れがあると膨らむ)
   ・監視=windowに張られた戦闘系リスナー数(pointermove/up/cancel/resize。
     バグ#30の犯人と同種のリーク。マウント中+1〜4、教室に戻ったら0が正常)
   ・heap=JSヒープ実測(Chrome系のみ。iPhoneでは出ない)
   表示はタップを一切吸わない小さな浮き札。リリース時はHZG_DEV_TOOLS=falseで丸ごと消える */
const LCNT={};   // 追跡するリスナーの純増カウント({once:true}系は追跡対象外の型なので狂わない)
{
  const TRACK=new Set(['pointermove','pointerup','pointercancel','resize']);
  const add=window.addEventListener.bind(window), rem=window.removeEventListener.bind(window);
  window.addEventListener=function(type,fn,opt){ if(TRACK.has(type)) LCNT[type]=(LCNT[type]||0)+1; return add(type,fn,opt); };
  window.removeEventListener=function(type,fn,opt){ if(TRACK.has(type)) LCNT[type]=(LCNT[type]||0)-1; return rem(type,fn,opt); };
}
function memMeterOn(){
  try{ if(typeof SETTINGS!=='undefined' && SETTINGS && 'memMeter' in SETTINGS) return SETTINGS.memMeter!==false; }catch(e){}
  try{ const s=JSON.parse(localStorage.getItem('hzg_settings_v1')||'{}'); return s.memMeter!==false; }catch(e){}
  return true;
}
function audioBufMB(){
  let bytes=0;
  const sum=o=>{ if(!o) return; for(const k in o){ const b=o[k]; if(b && b.length && b.numberOfChannels) bytes+=b.length*b.numberOfChannels*4; } };
  try{ sum(window.__HZG_SFX_BUF); }catch(e){}
  try{ if(typeof seBuf!=='undefined') sum(seBuf); }catch(e){}
  try{ if(typeof bgmBuf!=='undefined') sum(bgmBuf); }catch(e){}
  return bytes/1048576;
}
function domCount(){
  let n=document.getElementsByTagName('*').length;
  try{
    const m=document.getElementById('raidMount');
    if(m) m.querySelectorAll('*').forEach(el=>{ if(el.shadowRoot) n+=el.shadowRoot.querySelectorAll('*').length; });
  }catch(e){}
  return n;
}
/* フレーム計(2026-08-18 K1指示)。実機でカクつきを数字で見るための表示。
   毎コマの間隔を1秒ぶん貯めて「平均コマ数/秒」と「その1秒でいちばん詰まったコマ」を出す。
   30fpsなら33ms、60fpsなら17msが基準で、大きく超えた回数がそのままカクつきの回数 */
const FR={last:0, gaps:[]};
(function frameLoop(){
  const t=performance.now();
  if(FR.last){ const g=t-FR.last; if(g<2000) FR.gaps.push(g); }
  FR.last=t;
  requestAnimationFrame(frameLoop);
})();
function frameStat(){
  const g=FR.gaps; FR.gaps=[];
  if(!g.length) return '';
  const fps=Math.round(1000/(g.reduce((a,b)=>a+b,0)/g.length));
  const worst=Math.round(Math.max(...g));
  const slow=g.filter(x=>x>50).length;   // 50ms超=はっきり分かるカクつき
  return fps+'fps 最悪'+worst+'ms'+(slow?' 詰まり'+slow:'');
}
let memEl=null;
/* GPUの板(まとめ絵)。テクスチャは使っている量に関わらず 幅×上限×4バイト を確保しているので、
   そこが常時の取り分。使用率と焼いた枚数は「これから溢れるか」の目安として並べる */
function glLine(){
  try{
    const st=(window.HZG_GLSHARE && HZG_GLSHARE.stats) ? HZG_GLSHARE.stats() : null;
    if(!st) return 'GPU板 まだ無し';
    const mb=((st.幅||2048)*(st.上限||0)*4/1048576).toFixed(0);
    return 'GPU板'+mb+'MB '+(st.使用率||'?')+' '+(st.枚数||0)+'枚'+(st.あふれ?' あふれ!':'');
  }catch(e){ return 'GPU板 ?'; }
}
/* 読み込んだ絵の量。展開後の実バイトはブラウザが教えてくれないので、
   落としてきた枚数と伸長後の大きさを出す——増え続けているかを見るための目安 */
function imgLine(){
  try{
    const rs=performance.getEntriesByType('resource')
      .filter(r=>/\.(webp|png|jpe?g|svg)(\?|$)/i.test(r.name));
    const by=rs.reduce((a,r)=>a+(r.decodedBodySize||r.transferSize||0),0);
    return '絵'+rs.length+'枚 '+(by/1048576).toFixed(1)+'MB';
  }catch(e){ return '絵 ?'; }
}
function memTick(){
  if(!memMeterOn()){ if(memEl){ memEl.remove(); memEl=null; } return; }
  if(!memEl){
    memEl=document.createElement('div');
    memEl.id='hzgMemMeter';
    memEl.style.cssText='position:fixed; right:6px; top:94px; z-index:999998; pointer-events:none;'
      +'font:9px/1.5 -apple-system,Menlo,monospace; color:#d9ffe8; text-align:right;'
      +'background:rgba(10,20,14,.62); border:1px solid rgba(120,180,140,.4); border-radius:7px; padding:3px 6px;'
      +'text-shadow:0 1px 1px #000;';
    document.body.appendChild(memEl);
  }
  const ls=Object.entries(LCNT).filter(([,v])=>v>0).map(([k,v])=>v).reduce((a,b)=>a+b,0);
  /* ヒープの実測はChromeにしか無い(Safariはperformance.memoryを持たない=iPhoneでは空)。
     なので合計は名乗らず、食っている物の内訳を並べる(2026-08-25 K1採用=A案) */
  let heap='';
  try{ if(performance.memory) heap=' / heap'+(performance.memory.usedJSHeapSize/1048576).toFixed(0)+'MB'; }catch(e){}
  const fr=frameStat();
  memEl.textContent=(fr?fr+'\n':'')+glLine()+'\n'+imgLine()
    +'\n音'+audioBufMB().toFixed(1)+'MB / DOM'+domCount()+' / 監視'+ls+heap;
  memEl.style.whiteSpace='pre';
  memEl.style.borderColor=(ls>8||domCount()>9000) ? 'rgba(255,110,90,.8)' : 'rgba(120,180,140,.4)';   // 異常域は枠を赤く
}
setInterval(memTick, 1000);

/* ---------- ⑤ バグ報告箱(2026-08-18 K1正式依頼) ----------
   常設の「報告」ボタン→押した瞬間の状態を先に確定→専用の入れ物(IndexedDB)へ貯める。
   何件でも貯めて、後からメモを足し、まとめて出せる。ゲームのセーブとは別なので混ざらない。
   出したJSONは bugview.html で読み込んで同じ場面を作り直せる。 */
const BB_DB='hzg_bugbox_v1', BB_STORE='reports';
let bbBtn=null, bbWin=null, bbCount=0;
/* バグ報告を書いている間は襲撃の時間を止める(2026-08-31 K1指示
   「バグ報告押したらその間は時間停止するようにしろよそもそも」)。
   作戦タイムとは別の口を使う——作戦タイムだと彩度が落ちてHPバーと狙い線が出るので、
   報告のスクショに写る画そのものが変わってしまう。こちらは見た目を変えずに時間だけ止める。
   襲撃をやっていない時は何も起きない(教室に止める時間が無い) */
function bbTimeHold(on){
  try{ if(typeof battleHandle!=='undefined' && battleHandle && battleHandle.reportHold) battleHandle.reportHold(!!on); }catch(e){}
}
/* 報告の窓が2つとも閉じたら時間を戻す。どちらか一方が開いている間は止めたまま */
function bbHoldSync(){ bbTimeHold(!!(bbWin || bbQuickWin)); }

/* --- 直近の出来事(軽い輪) --- */
const BB_EV=[], BB_EV_MAX=120;
window.HZG_EV=(tag, data)=>{
  try{
    BB_EV.push({t:Math.round(performance.now()), tag:String(tag).slice(0,40),
      d:(data==null?null:String(typeof data==='object'?JSON.stringify(data):data).slice(0,120))});
    if(BB_EV.length>BB_EV_MAX) BB_EV.shift();
  }catch(e){}
};
/* 本編のログ関数が生えたら、そこも出来事として拾う(二重に包まない) */
{
  const hook=()=>{
    for(const n of ['logF','logS','logA','logHead']){
      const f=window[n];
      if(typeof f==='function' && !f.__bb){
        const w=function(){ try{ window.HZG_EV(n, arguments[0]); }catch(e){} return f.apply(this, arguments); };
        w.__bb=true; window[n]=w;
      }
    }
  };
  hook();
  const iv=setInterval(hook, 2000);
  setTimeout(function(){ clearInterval(iv); }, 60000);
}
/* --- 落ちた時の記録 --- */
const BB_ERR=[];
function bbErr(o){ BB_ERR.push(o); if(BB_ERR.length>20) BB_ERR.shift(); }
window.addEventListener('error', function(e){
  bbErr({t:new Date().toISOString(), kind:'error', msg:String(e.message||'').slice(0,200),
    at:String(e.filename||'').split('/').pop()+':'+e.lineno,
    stack:String((e.error&&e.error.stack)||'').slice(0,700)});
});
window.addEventListener('unhandledrejection', function(e){
  const r=e.reason;
  bbErr({t:new Date().toISOString(), kind:'promise', msg:String((r&&r.message)||r||'').slice(0,200),
    stack:String((r&&r.stack)||'').slice(0,700)});
});
/* --- 絵や音の読み込み失敗 --- */
const BB_IMGERR=[];
window.addEventListener('error', function(e){
  const t=e.target;
  if(t && (t.tagName==='IMG'||t.tagName==='AUDIO') && t.src){
    const s=String(t.src).split('/').pop();
    if(BB_IMGERR.indexOf(s)<0 && BB_IMGERR.length<20) BB_IMGERR.push(s);
  }
}, true);

/* --- コマ落ちの記録(直近10秒) --- */
const BB_FPS=[];
/* JSの忙しさも録る(2026-08-19 K1の切り分け用):
   「コマが遅い」だけでは、JSが忙しいのか・描画が重いのか・端末側の事情かが分からない。
   長いタスク(50ms超)の合計時間を録り、直近10秒の「主スレッドが塞がっていた割合」を出す */
let BB_BUSY=[];
try{
  new PerformanceObserver(function(list){
    for(const e of list.getEntries()) BB_BUSY.push([performance.now(), e.duration]);
  }).observe({entryTypes:['longtask']});
}catch(e){}
/* 1コマの実際の仕事時間(rAFの中身のms)。「長いタスク0」は50ms超が無いだけで、
   毎コマ15ms級の仕事は写らない——60fpsに必要なのは毎コマ16ms以内なのでこちらが本命(2026-08-19) */
let BB_WORK=[];
(function(){
  const orig=window.requestAnimationFrame.bind(window);
  window.requestAnimationFrame=function(cb){
    return orig(function(t){
      const a2=performance.now();
      cb(t);
      const ms=performance.now()-a2;
      if(ms>0.2) BB_WORK.push([a2, ms]);
      while(BB_WORK.length && a2-BB_WORK[0][0]>10000) BB_WORK.shift();
    });
  };
})();
(function bbFrame(){
  const t=performance.now();
  if(bbFrame._l){ const g=t-bbFrame._l; if(g<2000) BB_FPS.push([t,g]); }
  bbFrame._l=t;
  while(BB_FPS.length && t-BB_FPS[0][0]>10000) BB_FPS.shift();
  while(BB_BUSY.length && t-BB_BUSY[0][0]>10000) BB_BUSY.shift();
  requestAnimationFrame(bbFrame);
})();
function bbFps(){
  if(!BB_FPS.length) return null;
  const g=BB_FPS.map(function(x){ return x[1]; }).sort(function(a,b){ return a-b; });
  const q=function(p){ return Math.round(g[Math.min(g.length-1, Math.floor(g.length*p))]*10)/10; };
  const avg=g.reduce(function(a,b){ return a+b; },0)/g.length;
  const busy=BB_BUSY.reduce(function(a,x){ return a+x[1]; },0);
  const span=BB_FPS.length?(BB_FPS[BB_FPS.length-1][0]-BB_FPS[0][0]):1;
  const wk=BB_WORK.map(function(x){ return x[1]; }).sort(function(a,b){ return a-b; });
  const wq=function(p2){ return wk.length?Math.round(wk[Math.min(wk.length-1,Math.floor(wk.length*p2))]*10)/10:0; };
  const wsum=wk.reduce(function(a,b){ return a+b; },0);
  return { コマ数:g.length, fps:Math.round(1000/avg), 中央:q(.5), 遅い方1割:q(.9), 最悪:q(.999),
    詰まり:g.filter(function(x){ return x>50; }).length,
    JSが塞いだ割合:Math.round(busy/Math.max(1,span)*100)+'%', 長いタスク:BB_BUSY.length,
    コマ仕事:{中央:wq(.5), 遅い方1割:wq(.9), 最悪:wq(.999), 割合:Math.round(wsum/Math.max(1,span)*100)+'%'} };
}

/* --- 入れ物(IndexedDB。セーブとは別) --- */
function bbOpen(){
  return new Promise(function(res,rej){
    const q=indexedDB.open(BB_DB, 1);
    q.onupgradeneeded=function(){ const db=q.result;
      if(!db.objectStoreNames.contains(BB_STORE)) db.createObjectStore(BB_STORE, {keyPath:'id'}); };
    q.onsuccess=function(){ res(q.result); };
    q.onerror=function(){ rej(q.error); };
  });
}
function bbAll(){
  return bbOpen().then(function(db){ return new Promise(function(res,rej){
    const st=db.transaction(BB_STORE,'readonly').objectStore(BB_STORE);
    const r=st.getAll(); r.onsuccess=function(){ res(r.result||[]); }; r.onerror=function(){ rej(r.error); };
  }); });
}
function bbPut(rep){
  return bbOpen().then(function(db){ return new Promise(function(res,rej){
    const tx=db.transaction(BB_STORE,'readwrite');
    tx.objectStore(BB_STORE).put(rep);
    tx.oncomplete=function(){ res(true); }; tx.onerror=function(){ rej(tx.error); };
  }); });
}
function bbDel(id){
  return bbOpen().then(function(db){ return new Promise(function(res,rej){
    const tx=db.transaction(BB_STORE,'readwrite');
    tx.objectStore(BB_STORE).delete(id);
    tx.oncomplete=function(){ res(true); }; tx.onerror=function(){ rej(tx.error); };
  }); });
}

/* --- 押した瞬間の状態を確定する(UIを開く前に必ずこれを呼ぶ) --- */
function bbCapture(){
  /* 画面の実寸(2026-08-26 K1報告「下段ラベルが埋まる」の計測)。
     手元のヘッドレスでは再現しないので、実機の生の数字をここで拾う。
     レイアウト用の高さ・実際に見えている高さ・拡大率・横のはみ出し・板の位置 */
  const gaMen=(()=>{ try{
    const vv=window.visualViewport, el=document.getElementById('tabBar');
    const q=el?el.getBoundingClientRect():null;
    const probe=document.createElement('div');
    probe.style.cssText='position:fixed;left:0;top:0;width:1px;height:100dvh;visibility:hidden';
    document.body.appendChild(probe);
    const dvh=Math.round(probe.getBoundingClientRect().height);
    probe.style.height='100vh'; const vh=Math.round(probe.getBoundingClientRect().height);
    probe.remove();
    let widest=null, wmax=0;
    document.querySelectorAll('*').forEach(e=>{ const r=e.getBoundingClientRect();
      if(r.width&&r.right>wmax){ wmax=r.right; widest=e.tagName+(e.id?'#'+e.id:''); } });
    return { 見えている高さ:vv?Math.round(vv.height):null, 見えている幅:vv?Math.round(vv.width):null,
      拡大率:vv?+vv.scale.toFixed(3):null, ずれ上:vv?Math.round(vv.offsetTop):null,
      innerHeight:window.innerHeight, innerWidth:window.innerWidth,
      dvh:dvh, vh:vh, 文書の幅:document.documentElement.scrollWidth,
      vvh変数:getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim()||'(なし)',
      下段ラベル:q?[Math.round(q.top),Math.round(q.bottom),Math.round(q.height)]:null,
      一番右に出ている物:widest+' '+Math.round(wmax) };
  }catch(e){ return {読めず:String(e)}; } })();
  const rep={ 画面:gaMen, id:'R'+Date.now().toString(36)+Math.random().toString(36).slice(2,5),
    at:new Date().toISOString(), memo:'',
    build:window.HZG_BUILD||'?',
    url:location.pathname+location.search,
    ua:String(navigator.userAgent).slice(0,180),
    view:{w:innerWidth, h:innerHeight, dpr:window.devicePixelRatio||1},
    /* SETTINGSはトップレベルconst(windowに載らない)——window.SETTINGSは常にundefinedで
       GPU設定中でも'dom'と記録されていた(2026-08-20 Codex指摘)。裸の識別子で読む */
    draw:(typeof SETTINGS!=='undefined' && SETTINGS.gl) ? 'webgl' : 'dom',
    fps:bbFps(), errors:BB_ERR.slice(-8), imgErrors:BB_IMGERR.slice(0,10),
    /* メモリの内訳(2026-08-26 K1報告「画面が一切描き直されない・音とタップ音だけ生きている・
       アプリを立ち上げ直すと直る」の原因特定用)。これまで画面のメーターにしか出ておらず、
       報告のJSONには入っていなかった。iOSはメモリ逼迫でタブの合成だけ止めることがあるので、
       止まった時の実数を残す */
    mem:(()=>{ try{
      const gl=(window.HZG_GLSHARE&&HZG_GLSHARE.stats)?HZG_GLSHARE.stats():null;
      return {
        音MB:+audioBufMB().toFixed(1),
        GPU板MB: gl? +(((gl.幅||2048)*(gl.上限||0)*4/1048576).toFixed(0)) : null,
        GPU板使用率: gl? (gl.使用率||null) : null,
        焼いた枚数: gl? (gl.枚数||null) : null,
        画像枚数: (()=>{ try{ return performance.getEntriesByType('resource').filter(r=>r.initiatorType==='img').length; }catch(e){ return null; } })(),
        JSヒープMB: (performance.memory&&performance.memory.usedJSHeapSize)? +(performance.memory.usedJSHeapSize/1048576).toFixed(1) : null,
        画面の状態: document.visibilityState,
      };
    }catch(e){ return {読めず:String(e)}; } })(),
    /* 教室の描き手の健康状態(2026-08-26 K1報告「教室の絵だけ止まった・音楽は正常」の原因特定用)。
       これまで報告に載っていたのは襲撃側のglAbnだけで、教室が描けているかは記録が無かった。
       板を持っているか/最後に描けてから何秒経ったか/画面に板が載っているか、で止まった枝が分かる */
    clsDraw:(()=>{ try{
      const C=window.ClassGL||null;
      const sc=document.getElementById('scene');
      const cv=sc?sc.querySelector('canvas'):null;
      return {
        板を持っている: C?!!C.owning:null,
        最後に描けてから: (C&&C.lastDraw)? +((performance.now()-C.lastDraw)/1000).toFixed(1) : null,
        画面に板がある: !!cv,
        板の大きさ: cv? cv.width+'x'+cv.height : null,
        GPU設定: (typeof SETTINGS!=='undefined') ? {全体:!!SETTINGS.gl, 教室:!!SETTINGS.glClass} : null,
        教室の印: document.body.classList.contains('glcOn'),
        止まった関所: (C&&C.why)||null,   // 2026-09-01 K1指示: 描いていない時、どの関所で戻っているか
      };
    }catch(e){ return {読めず:String(e)}; } })(),
    /* ロード画面の失敗記録(2026-08-29 Codex納品キットの依頼)。
       準備が間に合わなかった/例外で落ちた時だけ中身が入る。空なら順調 */
    loadLog:(()=>{ try{ return (window.__HZG_LOADLOG||[]).slice(-12); }catch(e){ return null; } })(),
    /* 音の記録(2026-08-29 K1依頼)。タイトルの曲の頭が壊れる件の断定用。
       時刻・曲の位置・読み込み具合(0〜4)・音量を、下ごしらえから鳴らし終わりまで並べてある。
       読み方: 「鳴らし始めて+○ms」の行で位置が滑らかに進んでいなければ、そこが壊れた瞬間。
       曲の取得の「控えから:true」はキャッシュから来たという意味(タスクキル直後の起動で出る) */
    bgmLog:(()=>{ try{ return (window.__HZG_BGMLOG||[]).slice(-90); }catch(e){ return null; } })(),
    /* プロローグADVの文字送りの記録(2026-09-02 K1指示)。実機で「2文字目から1秒詰まる」件の断定用。
       行ごとに、何文字目が何msに「置かれ」何msに「出た」かが並ぶ。
       読み方:
       ・置いた が26msずつ進んでいない → 本体(JS)がそこで止まっている
       ・置いた は26msずつなのに 出た が飛んでいる → 絵が追いつけていない(描画側)
       ・前のコマから が大きい所 = 画面が止まっていた長さ
       幕が「開きながら」の行は、プロローグの1行目(700msかけて幕を開けている最中) */
    advLog:(()=>{ try{ return (window.__HZG_ADVLOG||[]).slice(-6); }catch(e){ return null; } })(),
    /* 指の操作が何msかかったか(2026-09-02)。10ms以上かかった物だけ載せる。
       文字送りが止まった時刻と重なっていれば、その出来事の処理が犯人 */
    evLog:(()=>{ try{ return (window.__HZG_EVLOG||[]).filter(e=>e.かかった>=10).slice(-24); }catch(e){ return null; } })(),
    /* 浮き文字・吹き出しの位置(2026-09-02 K1報告「一瞬上に行ってまた元の位置に戻る」)。
       直近3秒ぶんを、コマごと・物ごとに並べてある。
       読み方: 同じidの「上」を縦に追う。1コマだけ大きく減って次で戻っていれば、そいつが跳んだ物。
       跳び幅が46px前後なら、盤面の縦のずらし幅と同じ=座標の取り違え */
    /* 押下音の計器(2026-09-03 K1許可)。
       「呼ばれた」に対して「鳴った」が足りない時、その差がどの理由で消えたかが分かる。
       状態別に interrupted や suspended が積まれていれば、音の口が寝ていたのが原因 */
    kmLog:(()=>{ try{ return window.__HZG_KMLOG||null; }catch(e){ return null; } })(),
    /* 曲の出入り(2026-09-03 K1指示)。直近80件。
       「かけようとした」に対して「鳴らし始めた」が無ければ、その次の行に理由が出ている */
    bgmEv:(()=>{ try{ return (window.__HZG_BGMEV||[]).slice(-40); }catch(e){ return []; } })(),
    /* 曲の位置の物差し(2026-09-04 K1指示「計器をたせ」)。0.5秒ごと・直近60秒ぶん。
       読み方: 「位置」を縦に追う。0付近を行ったり来たり=頭が繰り返している /
       同じ数字が続く=止まっている / 1回だけ大きく戻る=ふつうのループの継ぎ目 */
    bgmPos:(()=>{ try{ return (window.__HZG_BGMPOS||[]).slice(-120); }catch(e){ return []; } })(),
    /* 曲の要素自身の出来事。ゲームが頼んでいない再生や頭出しが混ざっていればここに出る */
    bgmElEv:(()=>{ if(!HZG_METERS.on('曲')) return []; try{ return (window.__HZG_BGMEL_EV||[]).slice(-60); }catch(e){ return []; } })(),
    /* 画面が隠れた・戻った時刻(2026-09-04)。今までは合計秒数しか無かった */
    visLog:(()=>{ if(!HZG_METERS.on('画面')) return []; try{ return (window.__HZG_VISLOG||[]).slice(-40); }catch(e){ return []; } })(),
    /* 効果音の計器(2026-09-04 K1指示「どの効果音がどのタイミングで鳴ったか」)。
       直近160件。「幕が画面に」がtrueの行は、K1 STUDIOのロゴが出ている間に鳴った音 */
    seLog:(()=>{ if(!HZG_METERS.on('効果音')) return []; try{ return (window.__HZG_SELOG||[]).slice(-160); }catch(e){ return []; } })(),
    /* 音の口(AudioContext)の顛末(2026-09-05 K1指示)。
       いつ作り、いつresumeを頼み、それが成功したか失敗したか、0.5秒後にどうなっていたか */
    acLog:(()=>{ if(!HZG_METERS.on('音の口')) return []; try{ return (window.__HZG_ACLOG||[]).slice(-120); }catch(e){ return []; } })(),
    /* 物理の重さ(2026-09-06 K1指示「計器足しとけよ」)。発熱の疑いの切り分け用。
       1コマぶんのミリ秒(直近240コマ)・平均・最悪・回したコマ数・
       そのうち何も動いていなかったコマ数・床の物の数・机や壁の数。
       「コマ仕事」(perf.raid)と並べれば、物理が占める割合が出る */
    /* ★スポット計器(2026-09-06 K1指示)。大群予告の曲が鳴らない件**専用**。直ったら消す */
    spotBgm:(()=>{ if(!HZG_METERS.on('スポット')) return []; try{ return (window.__HZG_SPOTBGM||[]).slice(-40); }catch(e){ return []; } })(),
    /* ★スポット計器(2026-09-06 K1指示「スポット計器つけとけ」)。
       床のバスケットボールがつかめない件**専用**。直ったら消す。
       入切の札に関わらず必ず載せる——この件は「記録が残らないこと」が問題なので、
       K1が計器をオフにしていても取りこぼさない */
    spotBall:(()=>{ try{ return (window.__HZG_SPOTBALL||[]).slice(-40); }catch(e){ return []; } })(),
    /* ★【スポット計器 20260912 掴まれたまま】K1報告 gctjwu「佐藤がゾンビに掴まれたまま離されなくなった」。直ったら消す。
       読み方: 「放した」の行=掴んでいない敵に掴まれたままだった(前ならそのまま固まっていた)瞬間。敵の状態と狙いで、どの道で外れたかが分かる */
    spotGrab:(()=>{ try{ return (window.__HZG_SPOTGRAB||[]).slice(-40); }catch(e){ return []; } })(),
    physPerf:(()=>{ if(!HZG_METERS.on('物理')) return null; try{
      const P=window.__HZG_PHYSPERF; if(!P) return null;
      const a=P.ms||[]; const n=a.length||1;
      const sum=a.reduce((x,y)=>x+y,0);
      return {平均ms:+(sum/n).toFixed(2), 最悪ms:+Math.max.apply(null,a.concat([0])).toFixed(2),
        回したコマ数:P.n, 何も動いていないコマ数:P.idle,
        無駄の割合:P.n?(+(P.idle/P.n*100).toFixed(1)+'%'):'-',
        台数:P.台数, 動いている:P.動いている, 机:P.机, 壁:P.壁, バリケード:P.バリケード,
        進めた回数:P.進めた回数, 直近のms:a.slice(-40)};
    }catch(e){ return null; } })(),
    /* 床の物(武器・ボール)の動き(2026-09-06 K1指示「バグ報告に計器足すなりしろ」)。
       0.1秒ごとに、教室か襲撃か・種類・位置・高さ・平面の速さ・回っている速さ・
       材質(空気抵抗/摩擦/跳ね返り)・世界を進めた刻み。K1報告「襲撃で床を滑ってる」の切り分け用。
       両方の場面が同じ一覧に入るので、そのまま並べて見比べられる */
    dropLog:(()=>{ if(!HZG_METERS.on('床の物')) return []; try{ return (window.__HZG_DROPLOG||[]).slice(-260); }catch(e){ return []; } })(),
    /* 起動の一部始終(2026-09-05 K1指示)。触った回数・幕が開いた時刻・判断材料が揃った時刻 */
    bootLog:(()=>{ if(!HZG_METERS.on('起動')) return []; try{ return (window.__HZG_BOOTLOG||[]).slice(-150); }catch(e){ return []; } })(),
    /* 絵が異常に大きくなった記録(2026-09-05 K1報告)。空なら一度も起きていない。
       「誰」がキャラの番号なら席の札の変形が原因、「写した枠」なら測り方(K)が原因 */
    bigLog:(()=>{ if(!HZG_METERS.on('絵の大きさ')) return []; try{ return (window.__HZG_BIG||[]).slice(-12); }catch(e){ return []; } })(),
    /* 今のKと盤面の実幅。上の記録と見比べる基準 */
    glK:(()=>{ try{ const sc=document.getElementById('scene'); if(!sc) return null;
      const r=sc.getBoundingClientRect();
      return {盤面の実幅:Math.round(r.width), K:+(r.width/390).toFixed(3)}; }catch(e){ return null; } })(),
    /* 必殺技の計器(2026-09-03 K1許可)。技の最中と直後3秒の様子。
       「進んだ」がほぼ0のまま続き、「道」が0なら、道が引けずに踏み込んでは戻される痙攣 */
    spLog:(()=>{ try{ const L=(window.__HZG_SPLOG||[]);
      return L.length ? L.slice(-160) : []; }catch(e){ return []; } })(),
    floatLog:(()=>{ try{ const L=(window.__HZG_FLOATLOG||[]);
      /* 跳んだ物だけに絞って載せる(全部載せると報告が太る) */
      const byId={};
      for(const f of L) for(const o of (f.物||[])){ (byId[o.id]=byId[o.id]||[]).push({t:f.t, 上:o.上, 字:o.字}); }
      const out={};
      for(const id in byId){ const a=byId[id]; let mx=0;
        for(let i=1;i<a.length;i++) mx=Math.max(mx, Math.abs(a[i].上-a[i-1].上));
        if(mx>=12) out[id+'('+a[0].字+')  最大'+Math.round(mx)+'px']=a.map(x=>x.上); }
      /* 盤面のずらしが動いていないか(跳びの本命)。値が変わったコマだけ並べる */
      const cam=[]; let pv=null;
      for(const f of L){
        const k=f.盤面の位置+'|'+f.親たち+'/'+f.送り+'/'+f.見える窓のずれ+'/'+f.ずらし+'/'+f.倍率+'/'+f.拡大+'/'+f.縦ずれ+'/'+f.箱の高さ;
        if(k!==pv){ cam.push({t:f.t, 盤面の位置:f.盤面の位置, 親たち:f.親たち, 送り:f.送り,
                              見える窓のずれ:f.見える窓のずれ, ずらし:f.ずらし, 倍率:f.倍率,
                              拡大:f.拡大, 縦ずれ:f.縦ずれ, 箱の高さ:f.箱の高さ}); pv=k; }
      }
      if(cam.length>1) out['★土台が動いた']=cam.slice(0,24);
      else if(cam.length===1) out['★土台は動いていない']=cam[0];
      return Object.keys(out).length? out : {跳んだ物:'なし(直近3秒)'};
    }catch(e){ return null; } })(),
    /* コマ仕事の内訳(2026-08-21): 襲撃ループ/教室ループの自己時間。19ms級の犯人をここで切り分ける */
    perf:(()=>{ try{ const q=a=>{ if(!a||!a.length) return null; const b=a.slice().sort((x,y)=>x-y);
        return {中央:+b[Math.floor(b.length/2)].toFixed(1), 遅い方1割:+b[Math.floor(b.length*0.9)].toFixed(1), 最悪:+b[b.length-1].toFixed(1)}; };
      const P=window.__hzgPerf||{}; return {襲撃ループ:q(P.raid), 教室ループ:q(P.cls)}; }catch(e){ return null; } })(),
    /* 切り替え工程の時刻印(全撤去TODO段0・2026-08-23): 実機のカクつきがどの工程間で起きたかを読む */
    switchLog:(()=>{ try{ return ((window.__hzgPerf||{}).sw||[]).slice(-24); }catch(e){ return null; } })(),
    /* 発熱計(2026-08-23 K1報告): 描画回数/秒と隠れている間の活動。隠れて動いた秒数>0なら異常 */
    heat:(()=>{ try{ const R=window.__heatRing||[]; if(!R.length) return null;
      const last=R.slice(-60);
      const sum=(a,k)=>a.reduce((x,y)=>x+y[k],0);
      const hid=R.filter(x=>x.h);
      return { 直近60秒:{ 教室描画_平均毎秒:+(sum(last,'p')/last.length).toFixed(1),
          教室描画_最大毎秒:Math.max.apply(null,last.map(x=>x.p)),
          戦闘描画_平均毎秒:+(sum(last,'d')/last.length).toFixed(1) },
        隠れていた秒数:hid.length,
        隠れて動いた秒数:hid.filter(x=>x.p>0||x.d>0).length };
    }catch(e){ return null; } })(),
    events:BB_EV.slice(-100) };
  try{
    const js=document.querySelector('script[src*="raid_battle.js"]');
    if(js) rep.jsver=String(js.getAttribute('src')||'').split('?')[1]||'';
  }catch(e){}
  try{ if(typeof game!=='undefined' && game){
    rep.screen={ phase:game.phase, day:game.day, slot:game.slot,
      alive:(game.students||[]).filter(function(s){ return ['healthy','injured','suspect'].indexOf(s.state)>=0; }).length };
    rep.daily=slimDaily(game);
  } }catch(e){ rep.screenErr=String(e).slice(0,120); }
  try{ if(typeof window.HZG_SNAPSHOT==='function') rep.raid=window.HZG_SNAPSHOT(); }catch(e){ rep.raidErr=String(e).slice(0,200); }
  try{ rep.dom=domCount(); }catch(e){}
  /* BGMの内部状態(2026-08-21 K1報告「小刻みにリピート」の原因特定用の覗き窓)。
     曲名・どちら側が鳴っているか・再生位置・終端との距離・停止/終了/エラーの旗を写す */
  try{
    const out={wanted:(typeof bgmWanted!=='undefined')?bgmWanted:undefined,
      playing:(typeof bgmSrc!=='undefined')?!!bgmSrc:undefined,
      ctx:(typeof actx!=='undefined'&&actx)?actx.state:null, tracks:{}};
    if(typeof bgmEls!=='undefined') for(const k in bgmEls){
      const e2=bgmEls[k]; if(!e2) continue;
      const side=s2=>({t:+(s2.el.currentTime||0).toFixed(2), dur:+(s2.el.duration||0).toFixed(2),
        paused:s2.el.paused, ended:s2.el.ended, ready:s2.el.readyState,
        gain:+(s2.g&&s2.g.gain?s2.g.gain.value:0).toFixed(3),
        err:s2.el.error?String(s2.el.error.code):null});
      out.tracks[k]={cur:e2.cur?(e2.cur===e2.a?'a':'b'):null, timer:!!e2.timer, a:side(e2.a), b:side(e2.b)};
    }
    const rb=window.__HZG_BGM_EL||{};
    for(const k in rb){ const e3=rb[k]; if(e3&&e3.el) out.tracks[k]={t:+(e3.el.currentTime||0).toFixed(2),
      paused:e3.el.paused, ended:e3.el.ended, gain:+(e3.g&&e3.g.gain?e3.g.gain.value:0).toFixed(3)}; }
    rep.bgm=out;
  }catch(e){ rep.bgmErr=String(e).slice(0,120); }
  /* IndexedDBはDOM要素や循環参照を保存できないので、ここで安全な形へ落としてから返す */
  try{ return JSON.parse(safeJson(rep)); }
  catch(e){ return {id:rep.id, at:rep.at, memo:'', err:'保存できない値が混じっていた: '+String(e).slice(0,120)}; }
}

/* --- 常設ボタンと件数バッジ --- */
const BB_MARU='①②③④⑤⑥⑦⑧⑨⑩';
function bbBuildBtn(){
  if(bbBtn) return;
  bbBtn=document.createElement('button');
  bbBtn.id='hzgBugBtn';
  bbBtn.type='button';
  /* 置き場所(2026-08-25 K1指示): 画面右の縦置きをやめ、下段ラベルの工作(右端)の真上へ横書きで置く。
     左右は工作ボタンの中心(5個並びなので幅の90%)に合わせ、下は下段ラベルのすぐ上(2026-08-25 K1指示でさらに10px下げた。--tabHは index.html の :root) */
  bbBtn.style.cssText='position:fixed; left:90%; bottom:calc(var(--tabH, 62px) - 4px); transform:translateX(-50%); z-index:2147483000;'
    +'font:bold 11px/1.15 -apple-system,sans-serif; color:#fff2e0; background:rgba(150,40,30,.8);'
    +'border:1px solid rgba(255,150,120,.65); border-radius:9px; padding:5px 8px; letter-spacing:.06em;'
    +'white-space:nowrap; box-shadow:0 2px 8px rgba(0,0,0,.5);'
    +'-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;';
  /* 押し方で分ける(2026-08-28 K1指示。テスターに使いやすく)。
     ・軽く押す = 「何が起きた?」だけ書いて送る小さな窓(テスター用)
     ・長押し   = 今までの報告箱(K1用。一覧・選択・書き出し)
     どちらの場合も、窓を出す前に必ず状態を確定する——画面を触ると状態が変わるため */
  var bbHold=null, bbLong=false;
  function bbStart(){ bbLong=false; clearTimeout(bbHold); bbTimeHold(true);
    bbHold=setTimeout(function(){ bbLong=true;
      const rep=bbCapture();
      bbPut(rep).then(function(){ bbRefresh(); bbOpenWin(rep.id); })
                .catch(function(e){ toast('報告の保存に失敗: '+e); });
    }, 600); }
  function bbEnd(ev){ clearTimeout(bbHold);
    if(bbLong){ bbLong=false; if(ev){ ev.preventDefault(); ev.stopPropagation(); } return; }
    if(ev){ ev.preventDefault(); ev.stopPropagation(); }
    bbQuickOpen();
  }
  bbBtn.addEventListener('pointerdown', function(ev){ ev.stopPropagation(); bbStart(); });
  bbBtn.addEventListener('pointerup', bbEnd);
  bbBtn.addEventListener('pointercancel', function(){ clearTimeout(bbHold); bbLong=false; });
  bbBtn.addEventListener('pointerleave', function(){ clearTimeout(bbHold); });
  bbBtn.addEventListener('click', function(ev){ ev.preventDefault(); ev.stopPropagation(); });
  document.body.appendChild(bbBtn);
  bbRefresh();
}
function bbRefresh(){
  bbAll().then(function(list){
    bbCount=list.length;
    if(bbBtn) bbBtn.textContent='報告'+(bbCount?BB_MARU[Math.min(9,bbCount-1)]:'');
  }).catch(function(){});
}

/* --- テスター用の小さな窓(2026-08-28 K1指示) ---
   軽く押した時に出る。書くのは「何が起きたか」だけで、裏では今までどおり
   押した瞬間の状態をJSONで確定して端末へ保存し、そのままサーバーへ送る。
   送った直後だけ「間違えて送った」を出す——消しはせず、印だけ付ける
   (K1が一覧で「これは間違いだな」と目で分かればいい、という指示) */
var bbQuickWin=null, bbLastSent=null;
function bbQuickClose(){ if(bbQuickWin){ bbQuickWin.remove(); bbQuickWin=null; } bbHoldSync(); }
function bbQuickOpen(){
  if(bbQuickWin) return;
  const rep=bbCapture();          /* ★窓を出す前に確定する */
  const w=document.createElement('div');
  bbQuickWin=w;
  w.style.cssText='position:fixed; inset:0; z-index:2147483002; display:flex; align-items:center;'
    +'justify-content:center; padding:18px; background:rgba(6,10,8,.72);'
    +'font:13px/1.6 -apple-system,"Hiragino Kaku Gothic ProN",sans-serif;';
  const box=document.createElement('div');
  box.style.cssText='width:100%; max-width:330px; background:#f7f2e2; color:#2e3a2e; border-radius:14px;'
    +'padding:16px 15px 14px; box-shadow:0 10px 30px rgba(0,0,0,.6);';
  box.innerHTML='<div style="font-size:15px;font-weight:900;letter-spacing:.04em">なにが起こったか教えてください</div>'
    +'<div style="margin-top:5px;font-size:11px;color:#6b6250;line-height:1.7">'
    +'簡単な説明で大丈夫です。画面の様子は自動で一緒に送られます。</div>';
  const ta=document.createElement('textarea');
  ta.placeholder='例: 扉を押したら動かなくなった';
  ta.style.cssText='width:100%; height:92px; margin-top:10px; padding:9px; border-radius:9px;'
    +'border:1px solid #b3a888; background:#fffdf5; color:#2e3a2e;'
    +'font:14px/1.6 inherit; resize:none; -webkit-appearance:none;';
  box.appendChild(ta);
  /* 画面の絵(2026-09-02 K1指示)。文字だけでも送れるし、絵に印を付けても送れる */
  const shotRow=document.createElement('div');
  shotRow.style.cssText='display:flex; gap:8px; align-items:center; margin-top:10px;';
  const shotBtn=document.createElement('button');
  shotBtn.type='button'; shotBtn.textContent='画面の絵を付ける';
  shotBtn.style.cssText='flex:1; padding:11px 6px; border:1px dashed #a8997a; border-radius:10px;'
    +'font-weight:900; font-size:13px; background:#efe8d4; color:#4a4436;';
  const shotThumb=document.createElement('img');
  shotThumb.style.cssText='display:none; width:46px; height:46px; object-fit:cover;'
    +'border-radius:8px; border:1px solid #b3a888;';
  const shotDel=document.createElement('button');
  shotDel.type='button'; shotDel.textContent='外す';
  shotDel.style.cssText='display:none; padding:11px 10px; border:0; border-radius:10px;'
    +'font-weight:900; font-size:12px; background:#ddd5c0; color:#4a4436;';
  shotRow.appendChild(shotBtn); shotRow.appendChild(shotThumb); shotRow.appendChild(shotDel);
  box.appendChild(shotRow);
  const shotHint=document.createElement('div');
  shotHint.style.cssText='margin-top:5px; font-size:10.5px; color:#8a8069; line-height:1.7;';
  shotHint.textContent='押すと今の画面がそのまま出ます。気になる所を指でなぞってください。';
  box.appendChild(shotHint);
  function shotShow(){
    const has=!!rep.shot;
    shotThumb.style.display=has?'block':'none';
    shotDel.style.display=has?'block':'none';
    if(has) shotThumb.src=rep.shot;
    shotBtn.textContent=has?'描き直す':'画面の絵を付ける';
    shotHint.textContent=has
      ? ('絵を付けました('+Math.round(rep.shot.length/1024)+'KB)。押すと描き直せます。')
      : '押すと今の画面がそのまま出ます。気になる所を指でなぞってください。';
  }
  shotBtn.onclick=function(ev){ ev.stopPropagation();
    shotBtn.disabled=true; shotBtn.textContent='画面を写しています…';
    /* 報告の窓が写り込まないよう、写している間だけ隠す */
    var w0=bbQuickWin; if(w0) w0.style.visibility='hidden';
    bbShotNow().then(function(url){
      if(w0) w0.style.visibility='';
      shotBtn.disabled=false; shotShow();
      if(url) bbShotEdit(url, function(u){ rep.shot=u; shotShow(); });
      else toast('画面を写せませんでした');
    }).catch(function(e){
      if(w0) w0.style.visibility='';
      shotBtn.disabled=false; shotShow(); toast('画面を写せなかった: '+e.message);
    });
  };
  shotDel.onclick=function(ev){ ev.stopPropagation(); rep.shot=null; shotShow(); };

  const row=document.createElement('div');
  row.style.cssText='display:flex; gap:8px; margin-top:12px;';
  const mk=function(label,bg,fg){
    const b=document.createElement('button'); b.type='button'; b.textContent=label;
    b.style.cssText='flex:1; padding:13px 6px; border:0; border-radius:10px; font-weight:900;'
      +'font-size:15px; background:'+bg+'; color:'+fg+';';
    row.appendChild(b); return b; };
  const cancel=mk('キャンセル','#ddd5c0','#4a4436');
  const send=mk('報告する','#a94037','#fff4ea');
  box.appendChild(row);
  const note=document.createElement('div');
  note.style.cssText='margin-top:10px; text-align:center; min-height:18px; font-size:11.5px; color:#6b6250;';
  box.appendChild(note);
  w.appendChild(box); document.body.appendChild(w);
  setTimeout(function(){ try{ ta.focus(); }catch(e){} }, 60);

  cancel.onclick=function(ev){ ev.stopPropagation(); bbQuickClose(); };
  w.addEventListener('click', function(ev){ if(ev.target===w) bbQuickClose(); });
  send.onclick=function(ev){
    ev.stopPropagation();
    send.disabled=true; cancel.disabled=true; send.textContent='送っています…';
    rep.memo=ta.value.slice(0,400);
    bbPut(rep).then(function(){ bbRefresh(); }).catch(function(){});
    const body=JSON.stringify(Object.assign({}, rep, {
      note:rep.memo, ua:navigator.userAgent,
      who:(function(){ try{ return localStorage.getItem('hzg_tester')||''; }catch(e){ return ''; } })()
    }));
    fetch('/api/bug', {method:'POST', headers:{'content-type':'application/json'}, body:body})
      .then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
      .then(function(d){ bbLastSent=d.id; bbQuickDone(box, note, true); })
      .catch(function(){ bbQuickDone(box, note, false); });
  };
}
/* --- 画面の絵を付ける(2026-09-02 K1指示) ---
   K1「スクショしてそこに印つけて送れる機能を作れ。従来の文字だけで送るか、
       その画面のスクショにまるとかをペンで書いて送るかできる感じに」

   絵はゲームの中で写さず、**端末で撮ったスクショを選んでもらう**。
   理由: GPU描画のcanvasはtoDataURLで空の絵になることがある(描いた直後でないと中身が残らない)。
   撮った本人の画面をそのまま持ってくる方が確実で、ゲームの外の崩れも写る。
   選んだ絵は900pxまで縮めてJPEGにする——1件512KBの上限に収めるため */
var BB_SHOT_MAX=1200, BB_SHOT_BYTES=260*1024;   // 900→1200(2026-09-04): 900だと3倍の画面から落としすぎて別物に見えていた
/* 今の画面をそのまま1枚の絵にする(2026-09-02 K1指示)。
   報告を押した瞬間ゲームの時間は止まっているので、次に描かれるコマは今見えている画そのもの。
   GPUの盤面は「描き終えた直後」でないと中身が残らないので、絵の受け取りはgl_sprites側の
   コマの終わりに仕込んである(window.__HZG_GRAB)。
   盤面が動いていない画面(タイトル・名簿など)では取れないので、その時は写真を選ぶ方へ回す */
function bbGrabScreen(){
  return new Promise(function(res){
    var done=false;
    try{
      window.__HZG_GRAB=function(url){ if(done) return; done=true; res(url||null); };
    }catch(e){ res(null); return; }
    setTimeout(function(){ if(done) return; done=true;
      try{ window.__HZG_GRAB=null; }catch(e){}
      res(null); }, 700);   // 描かれないまま待たされない
  });
}
/* 画面をその場で1枚の絵にする(2026-09-02 K1指示の作り直し)。
   K1「なんでiPhoneから写真を添付する画面出るんだよ。押したらその画面のスクショを
   そのままペイントして報告と一緒に送れるようにしろ」——写真選びへ逃げるのをやめる。
   ・盤面(教室・戦闘)はGPUが描いているので、コマの終わりに取る(__HZG_GRAB)
   ・それ以外の画面(購買部・名簿・タイトル…)はHTMLなので、html2canvasで写す
   ・両方ある時は、HTMLの絵の上へ盤面の絵を同じ位置で重ねる
   html2canvasは同梱(lib_html2canvas.js)。押した時に初めて読み込む */
function bbH2C(){
  if(window.html2canvas) return Promise.resolve(window.html2canvas);
  if(bbH2C._p) return bbH2C._p;
  return bbH2C._p=new Promise(function(res,rej){
    var sc=document.createElement('script');
    sc.src='lib_html2canvas.js?v=20260902zp';
    sc.onload=function(){ res(window.html2canvas); };
    sc.onerror=function(){ rej(new Error('画面を写す道具が読めなかった')); };
    document.head.appendChild(sc);
  });
}
/* 報告の窓など「写したくない物」を外す */
function bbShotSkip(el){
  try{
    if(!el || !el.classList) return false;
    if(el===bbQuickWin || el===bbWin || el===bbBtn) return true;
    if(el.id==='hzgUiedCross') return true;
    return false;
  }catch(e){ return false; }
}
/* 報告に付ける絵(2026-09-04 K1報告「iPhone13でそのまま表示されてるスクショになってねえよ」の直し)。
   直したのは2つ。
   ①**粗さ**: HTMLの写しを等倍(390x797)で作っていた。iPhone 13の実際の画面は3倍の細かさなので、
     字も線も潰れて別物に見えていた。端末の細かさぶん(上限2倍)で写す。
   ②**盤面の貼り位置**: GPUの板を重ねる時、置き場所を「文書で最初に見つかったcanvas」から
     取っていた。別のcanvasが先にあると見当違いの所へ貼られる。板そのものを名指しする。
   なお、iOSのSafariにはJavaScriptから本物のスクショを撮る方法が無い。
   HTMLの部分は html2canvas が描き直した近似で、盤面だけが本物の画素。 */
function bbGlCanvas(){
  try{ if(window.__HZG_GLCANVAS && window.__HZG_GLCANVAS.getBoundingClientRect) return window.__HZG_GLCANVAS; }catch(e){}
  /* 名指しが無い時は、画面に出ている一番大きいcanvasを選ぶ(最初の1個ではなく) */
  try{
    var best=null, area=0;
    var all=document.querySelectorAll('canvas');
    for(var i=0;i<all.length;i++){
      var r=all[i].getBoundingClientRect();
      if(r.width<40 || r.height<40) continue;
      if(r.width*r.height>area){ area=r.width*r.height; best=all[i]; }
    }
    return best;
  }catch(e){ return null; }
}
function bbShotNow(){
  var K=Math.max(1, Math.min(2, window.devicePixelRatio||1));   // 端末の細かさぶん(上限2倍)
  return bbH2C().then(function(h2c){
    return h2c(document.body, {
      backgroundColor:'#000', scale:K, logging:false, useCORS:true, allowTaint:true,
      width:window.innerWidth, height:window.innerHeight,
      windowWidth:window.innerWidth, windowHeight:window.innerHeight,
      ignoreElements:bbShotSkip
    });
  }).then(function(page){
    /* 盤面(GPU)が動いていれば、その絵を同じ場所へ重ねる */
    return bbGrabScreen().then(function(gl){
      if(!gl) return page.toDataURL('image/jpeg', .9);
      return new Promise(function(res){
        var im=new Image();
        im.onload=function(){
          try{
            var el=bbGlCanvas();
            var g=page.getContext('2d');
            if(el){
              var r=el.getBoundingClientRect();
              /* html2canvasの絵はK倍で作ってあるので、貼る枠も同じ倍率に合わせる */
              g.drawImage(im, r.left*K, r.top*K, r.width*K, r.height*K);
            }
            res(page.toDataURL('image/jpeg', .9));
          }catch(e){ res(page.toDataURL('image/jpeg', .9)); }
        };
        im.onerror=function(){ res(page.toDataURL('image/jpeg', .9)); };
        im.src=gl;
      });
    });
  });
}
function bbShotPick(cb){
  var inp=document.createElement('input');
  inp.type='file'; inp.accept='image/*';
  inp.style.cssText='position:fixed;left:-9999px;top:0;';
  document.body.appendChild(inp);
  inp.onchange=function(){
    var f=inp.files && inp.files[0];
    inp.remove();
    if(!f) return;
    var fr=new FileReader();
    fr.onload=function(){ bbShotEdit(String(fr.result), cb); };
    fr.readAsDataURL(f);
  };
  inp.click();
}
/* 絵の上に指で描く窓。丸でも矢印でも、指で好きに */
function bbShotEdit(src, cb){
  var im=new Image();
  im.onload=function(){
    var k=Math.min(1, BB_SHOT_MAX/Math.max(im.width, im.height));
    var W=Math.round(im.width*k), H=Math.round(im.height*k);
    var base=document.createElement('canvas'); base.width=W; base.height=H;
    base.getContext('2d').drawImage(im,0,0,W,H);

    var w=document.createElement('div');
    w.style.cssText='position:fixed; inset:0; z-index:2147483003; background:#0b0e0c;'
      +'display:flex; flex-direction:column; touch-action:none;'
      +'font:13px/1.6 -apple-system,"Hiragino Kaku Gothic ProN",sans-serif; color:#f7f0dd;';
    var head=document.createElement('div');
    head.style.cssText='flex:0 0 auto; padding:calc(8px + env(safe-area-inset-top)) 10px 8px;'
      +'display:flex; gap:6px; align-items:center; background:#1a241e; border-bottom:1px solid #3d5c4c;';
    /* 確定と取り消しは上に小さく置く。下に6個並べると折り返して潰れ、
       どれで確定するのか分からなくなっていた(2026-09-03 K1指摘) */
    var bCancel=document.createElement('button'); bCancel.type='button'; bCancel.textContent='やめる';
    bCancel.style.cssText='flex:0 0 auto;padding:6px 11px;border:0;border-radius:8px;'
      +'font-weight:900;font-size:12px;background:#3d5c4c;color:#cfe0d5;';
    var ttl=document.createElement('span');
    ttl.style.cssText='flex:1 1 auto;text-align:center;font-weight:900;font-size:12.5px;';
    ttl.textContent='気になる所に印を付ける';
    var bOk=document.createElement('button'); bOk.type='button'; bOk.textContent='確定';
    bOk.style.cssText='flex:0 0 auto;padding:6px 15px;border:0;border-radius:8px;'
      +'font-weight:900;font-size:12.5px;background:#e0b048;color:#1a241e;';
    head.appendChild(bCancel); head.appendChild(ttl); head.appendChild(bOk);
    var stage=document.createElement('div');
    stage.style.cssText='flex:1 1 auto; min-height:0; display:flex; align-items:center;'
      +'justify-content:center; padding:6px; position:relative;';
    var wrap=document.createElement('div');
    wrap.style.cssText='position:relative; max-width:100%; max-height:100%;';
    base.style.cssText='display:block; max-width:100%; max-height:100%; width:auto; height:auto;';
    var pen=document.createElement('canvas'); pen.width=W; pen.height=H;
    pen.style.cssText='position:absolute; left:0; top:0; width:100%; height:100%; touch-action:none;';
    wrap.appendChild(base); wrap.appendChild(pen); stage.appendChild(wrap);

    var foot=document.createElement('div');
    foot.style.cssText='flex:0 0 auto; padding:8px 10px calc(8px + env(safe-area-inset-bottom));'
      +'background:#1a241e; border-top:1px solid #3d5c4c; display:flex; gap:6px;';
    function btn(label,bg,fg,grow){
      var b=document.createElement('button'); b.type='button'; b.textContent=label;
      b.style.cssText='flex:'+(grow||1)+' 1 0; padding:11px 4px; border:0; border-radius:10px;'
        +'font-weight:900; font-size:13px; background:'+bg+'; color:'+fg+';';
      foot.appendChild(b); return b;
    }
    var col='#ff3b2f';
    var bRed=btn('赤','#ff3b2f','#fff'), bYel=btn('黄','#ffd23b','#3a2c00');
    var bUndo=btn('1つ戻す','#3d5c4c','#e8f0ea'), bClr=btn('全部消す','#3d5c4c','#e8f0ea');
    function mark(){ bRed.style.outline=(col==='#ff3b2f')?'3px solid #fff':'none';
                     bYel.style.outline=(col==='#ffd23b')?'3px solid #fff':'none'; }
    bRed.onclick=function(){ col='#ff3b2f'; mark(); };
    bYel.onclick=function(){ col='#ffd23b'; mark(); };
    mark();

    w.appendChild(head); w.appendChild(stage); w.appendChild(foot);
    document.body.appendChild(w);

    var g=pen.getContext('2d');
    g.lineCap='round'; g.lineJoin='round';
    var strokes=[], cur=null, id=null;
    function redraw(){
      g.clearRect(0,0,W,H);
      for(var i=0;i<strokes.length;i++){
        var st=strokes[i]; if(st.p.length<2) continue;
        g.strokeStyle=st.c; g.lineWidth=st.w;
        g.beginPath(); g.moveTo(st.p[0].x, st.p[0].y);
        for(var q=1;q<st.p.length;q++) g.lineTo(st.p[q].x, st.p[q].y);
        g.stroke();
      }
    }
    function pt(ev){ var r=pen.getBoundingClientRect();
      return {x:(ev.clientX-r.left)*(W/r.width), y:(ev.clientY-r.top)*(H/r.height)}; }
    pen.addEventListener('pointerdown', function(ev){
      if(id!==null) return; id=ev.pointerId;
      try{ pen.setPointerCapture(id); }catch(e){}
      cur={c:col, w:Math.max(3, Math.round(W/110)), p:[pt(ev)]};
      strokes.push(cur); ev.preventDefault();
    });
    pen.addEventListener('pointermove', function(ev){
      if(ev.pointerId!==id || !cur) return;
      cur.p.push(pt(ev)); redraw(); ev.preventDefault();
    });
    function up(ev){ if(ev.pointerId!==id) return; id=null; cur=null; }
    pen.addEventListener('pointerup', up);
    pen.addEventListener('pointercancel', up);

    bUndo.onclick=function(){ strokes.pop(); redraw(); };
    bClr.onclick=function(){ strokes.length=0; redraw(); };
    bCancel.onclick=function(){ w.remove(); };
    bOk.onclick=function(){
      var out=document.createElement('canvas'); out.width=W; out.height=H;
      var o=out.getContext('2d');
      o.drawImage(base,0,0); o.drawImage(pen,0,0);
      var q=0.72, url=out.toDataURL('image/jpeg', q);
      while(url.length>BB_SHOT_BYTES && q>0.35){ q-=0.12; url=out.toDataURL('image/jpeg', q); }
      w.remove();
      cb(url);
    };
  };
  im.onerror=function(){ toast('その絵は読めませんでした'); };
  im.src=src;
}
/* 送った後の姿。ここで初めて「間違えて送った」を出す */
function bbQuickDone(box, note, ok){
  box.innerHTML='';
  const h=document.createElement('div');
  h.style.cssText='font-size:15px;font-weight:900;letter-spacing:.04em;text-align:center;';
  h.textContent = ok ? 'ありがとうございます。送りました。' : '送れませんでした';
  box.appendChild(h);
  if(!ok){   // 失敗した時だけ、どうすればいいかを1行だけ出す
    const p=document.createElement('div');
    p.style.cssText='margin-top:7px;font-size:11.5px;color:#6b6250;line-height:1.7;text-align:center;';
    p.textContent='電波の届く所でもう一度お願いします。';
    box.appendChild(p);
  }
  const row=document.createElement('div');
  row.style.cssText='display:flex; gap:8px; margin-top:13px;';
  const close=document.createElement('button');
  close.type='button'; close.textContent='閉じる';
  close.style.cssText='flex:1; padding:13px 6px; border:0; border-radius:10px; font-weight:900;'
    +'font-size:15px; background:#28533f; color:#f7f0dd;';
  close.onclick=function(ev){ ev.stopPropagation(); bbQuickClose(); };
  row.appendChild(close);
  box.appendChild(row);
  if(ok && bbLastSent){
    const oops=document.createElement('button');
    oops.type='button'; oops.textContent='間違えて送ってしまった';
    oops.style.cssText='width:100%; margin-top:9px; padding:11px 6px; border:0; border-radius:10px;'
      +'font-weight:700; font-size:13px; background:#e6dcc4; color:#7a3b32;';
    oops.onclick=function(ev){
      ev.stopPropagation();
      oops.disabled=true; oops.textContent='印を付けています…';
      fetch('/api/bug?oops=1&id='+encodeURIComponent(bbLastSent), {cache:'no-store'})
        .then(function(){ oops.textContent='「間違い」と印を付けました'; })
        .catch(function(){ oops.textContent='印を付けられませんでした'; });
    };
    box.appendChild(oops);
  }
}

/* --- 一覧の画面 --- */
function bbOpenWin(focusId){
  if(bbWin){ bbWin.remove(); bbWin=null; }
  bbWin=document.createElement('div');
  bbWin.style.cssText='position:fixed; inset:4% 3%; z-index:2147483001; background:rgba(10,16,13,.97);'
    +'border:1px solid #4d7a5f; border-radius:12px; padding:9px; display:flex; flex-direction:column; gap:7px;'
    +'font:12px/1.5 -apple-system,sans-serif; color:#e6f2ea;';
  const head=document.createElement('div');
  head.style.cssText='display:flex; align-items:center; gap:6px;';
  head.innerHTML='<b style="font-size:13px">バグ報告箱</b>'
    +'<span style="font-size:10px;opacity:.7">押した瞬間の状態を保存しました</span>';
  const close=document.createElement('button');
  close.textContent='閉じる';
  close.style.cssText='margin-left:auto; padding:6px 10px; border:0; border-radius:8px; background:#28533f; color:#f7f0dd; font-weight:bold;';
  close.onclick=function(){ bbWin.remove(); bbWin=null; bbHoldSync(); };
  head.appendChild(close);
  const listEl=document.createElement('div');
  listEl.style.cssText='flex:1; overflow:auto; display:flex; flex-direction:column; gap:6px; -webkit-overflow-scrolling:touch;';
  /* 計器の入切(2026-09-06 K1指示「規定は全部オフ」「完全にオフのモードも作れ」)。
     追う時だけ点けて、終わったら消す。切ってある計器は記録を作る所へ入らないので、
     点けていない間は本当に何もしない */
  const met=document.createElement('div');
  met.style.cssText='display:flex; gap:4px; flex-wrap:wrap; align-items:center;'
    +'padding:6px 0 2px; border-top:1px solid #3d5c4c; margin-top:4px;';
  const NAMES=['効果音','床の物','必殺技','絵の大きさ','起動','音の口','曲','画面','コマ仕事','物理','スポット'];
  function metDraw(){
    met.innerHTML='<b style="font-size:10.5px; color:#ffd88a; flex:0 0 100%">計器'
      +(HZG_METERS.全部切る?'(全部切っています)':'')+'</b>';
    const all=document.createElement('button');
    all.textContent=HZG_METERS.全部切る?'全部入れる':'全部切る';
    all.style.cssText='font-size:10.5px; font-weight:bold; padding:6px 9px; border-radius:8px;'
      +'border:1px solid #b0523f; background:'+(HZG_METERS.全部切る?'#c2503a':'#2a1a16')+';'
      +'color:#f7f0dd;';
    all.onclick=function(){ HZG_METERS.set('全部切る', !HZG_METERS.全部切る); metDraw(); };
    met.appendChild(all);
    NAMES.forEach(function(nm){
      const b=document.createElement('button');
      const on=HZG_METERS.on(nm);
      b.textContent=nm;
      b.style.cssText='font-size:10.5px; font-weight:bold; padding:6px 9px; border-radius:8px;'
        +'border:1px solid #4d7a5f; background:'+(on?'#d98d2b':'#22322a')+';'
        +'color:'+(on?'#2b1a06':'#7f8f99')+';'
        +(HZG_METERS.全部切る?'opacity:.45;':'');
      b.onclick=function(){ HZG_METERS.set(nm, !HZG_METERS[nm]); metDraw(); };
      met.appendChild(b);
    });
    const note=document.createElement('div');
    note.style.cssText='flex:0 0 100%; font-size:9.5px; color:#8fa2ab; line-height:1.6; margin-top:3px;';
    note.textContent='橙=点いている。既定は「効果音・床の物・必殺技・絵の大きさ」が消えています'
      +'(毎回DOMを触ったり入れ物を作るので重い)。追う時だけ点けてください。';
    met.appendChild(note);
  }
  metDraw();
  const foot=document.createElement('div');
  foot.style.cssText='display:flex; gap:5px; flex-wrap:wrap;';
  bbWin.appendChild(head); bbWin.appendChild(listEl); bbWin.appendChild(met); bbWin.appendChild(foot);
  document.body.appendChild(bbWin);

  const sel={};
  function draw(){
    return bbAll().then(function(list){
      list.sort(function(a,b){ return a.at<b.at?1:-1; });
      listEl.innerHTML='';
      if(!list.length){ listEl.innerHTML='<div style="opacity:.6">まだ1件もありません</div>'; return; }
      list.forEach(function(rep){
        const row=document.createElement('div');
        row.style.cssText='background:#182821; border:1px solid #33564a; border-radius:9px; padding:7px;'
          +(rep.id===focusId?'outline:2px solid #d98d2b;':'');
        const t=new Date(rep.at);
        const hh=String(t.getHours()).padStart(2,'0')+':'+String(t.getMinutes()).padStart(2,'0');
        const r=rep.raid||{};
        const where=rep.screen ? (rep.screen.day+'日目 '+(rep.screen.phase||'')) : '(教室の外)';
        const info=[hh, where, (rep.draw==='webgl'?'GPU':'標準'),
          (r.zombies?('敵'+r.zombies.length):''),
          (rep.fps?(rep.fps.fps+'fps 最悪'+rep.fps.最悪+'ms'):''),
          ((rep.errors&&rep.errors.length)?('エラー'+rep.errors.length):'')].filter(Boolean).join(' / ');
        const line=document.createElement('div');
        line.style.cssText='display:flex; align-items:center; gap:6px;';
        const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=!!sel[rep.id];
        cb.onchange=function(){ sel[rep.id]=cb.checked; };
        const lab=document.createElement('span'); lab.textContent=info;
        lab.style.cssText='flex:1; font-size:11px;';
        const del=document.createElement('button'); del.textContent='削除';
        del.style.cssText='padding:5px 8px; border:0; border-radius:7px; background:#7a2f23; color:#ffe9e5; font-size:11px;';
        del.onclick=function(){ bbDel(rep.id).then(function(){ delete sel[rep.id]; bbRefresh(); draw(); }); };
        line.appendChild(cb); line.appendChild(lab); line.appendChild(del);
        const memo=document.createElement('input');
        memo.placeholder='何が起きた?(後から書ける)'; memo.value=rep.memo||'';
        memo.style.cssText='width:100%; margin-top:5px; padding:6px; border-radius:7px; border:1px solid #3d6b58;'
          +'background:#0e1a15; color:#e6f2ea; font-size:12px;';
        memo.onchange=function(){ rep.memo=memo.value; bbPut(rep); };
        row.appendChild(line); row.appendChild(memo);
        listEl.appendChild(row);
      });
    });
  }
  function pick(){
    return bbAll().then(function(l){
      const any=Object.keys(sel).some(function(k){ return sel[k]; });
      return any ? l.filter(function(r){ return sel[r.id]; }) : l;
    });
  }
  function out(list, how){
    if(!list.length){ toast('選ばれていません'); return; }
    const txt=JSON.stringify({bundleVersion:1, at:new Date().toISOString(), reports:list});
    const kb=Math.round(txt.length/102.4)/10;
    if(how==='copy'){
      if(navigator.clipboard && navigator.clipboard.writeText)
        navigator.clipboard.writeText(txt).then(function(){ toast('コピーした('+kb+'KB)'); })
          .catch(function(){ manualCopy(txt); });
      else manualCopy(txt);
    } else if(how==='share' && navigator.share){
      const f=new File([txt], 'hzg_bug.json', {type:'application/json'});
      const p=(navigator.canShare && navigator.canShare({files:[f]}))
        ? navigator.share({files:[f], title:'平成ゾンビ学園 バグ報告'})
        : navigator.share({title:'平成ゾンビ学園 バグ報告', text:txt.slice(0,60000)});
      p.catch(function(){ manualCopy(txt); });
    } else {
      const a=document.createElement('a');
      a.href=URL.createObjectURL(new Blob([txt],{type:'application/json'}));
      a.download='hzg_bug_'+new Date().toISOString().replace(/[:.]/g,'-').slice(0,19)+'.json';
      document.body.appendChild(a); a.click();
      setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); }, 1000);
      toast('保存した('+kb+'KB)');
    }
  }
  function mkBtn(label, fn, bg){
    const b=document.createElement('button'); b.textContent=label;
    b.style.cssText='flex:1; min-width:86px; padding:9px 6px; border:0; border-radius:9px; font-weight:bold;'
      +'background:'+(bg||'#28533f')+'; color:#f7f0dd;';
    b.onclick=fn; foot.appendChild(b); return b;
  }
  /* サーバーへ送る(2026-08-28 K1指示。テスターの報告が端末に溜まるだけだったのを、
     押した先でK1が読めるようにする)。宛先は同じCloudflareの /api/bug 。
     成功したら「送った」と出す。圏外などで失敗しても端末の箱には残るので、後でもう一度送れる */
  mkBtn('サーバーへ送る', function(){
    pick().then(function(l){
      if(!l.length){ toast('選ばれていません'); return; }
      var ok=0, ng=0, done=0;
      l.forEach(function(rep){
        var body=JSON.stringify(Object.assign({}, rep, {
          note:rep.memo||'', who:(function(){ try{ return localStorage.getItem('hzg_tester')||''; }catch(e){ return ''; } })(),
          ua:navigator.userAgent
        }));
        fetch('/api/bug', {method:'POST', headers:{'content-type':'application/json'}, body:body})
          .then(function(r){ if(r.ok) ok++; else ng++; })
          .catch(function(){ ng++; })
          .then(function(){ done++;
            if(done===l.length) toast(ok+'件 送った'+(ng?(' / '+ng+'件 失敗'):''));
          });
      });
    });
  }, '#3d6ab4');
  mkBtn('コピー', function(){ pick().then(function(l){ out(l,'copy'); }); });
  mkBtn('ファイル保存', function(){ pick().then(function(l){ out(l,'file'); }); }, '#8a6d1c');
  if(navigator.share) mkBtn('共有', function(){ pick().then(function(l){ out(l,'share'); }); }, '#3d6ab4');
  mkBtn('全部消す', function(){
    if(!confirm('全部消していい?')) return;
    bbAll().then(function(l){ return Promise.all(l.map(function(r){ return bbDel(r.id); })); })
      .then(function(){ bbRefresh(); draw(); });
  }, '#7a2f23');
  draw();
}
/* 起動時にボタンを出す(開発ツールが止まっていればここまで来ない) */
setTimeout(bbBuildBtn, 800);

})();