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