public/dev_tools.js

分割 4 / 10 · 元ファイル 578〜770行付近

原文の連続部分です。長い圧縮行は行の途中で分割する場合があります。全分割を順に連結するとファイル全文になります。

      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){