Project_WL/Tools/Pipeline/control_panel.html

223 lines
10 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WL Pipeline 제어판</title>
<style>
:root { color-scheme: dark; --bg:#14161a; --panel:#1e2128; --line:#2c3038; --fg:#e6e8eb; --dim:#9aa0a8; --accent:#4f8cff; --ok:#3ecf8e; --warn:#f5b14c; --err:#ff6b6b; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "Segoe UI", "Malgun Gothic", system-ui, sans-serif; }
header { display:flex; align-items:center; gap:14px; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--line); }
header h1 { font-size:16px; margin:0; }
header .proj { color:var(--dim); font-size:12px; margin-left:auto; }
main { display:grid; grid-template-columns: 1fr 1fr; gap:12px; padding:12px 16px; }
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
section h2 { font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); margin:0 0 8px; }
.wide { grid-column: 1 / -1; }
.row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
button { background:#2a2f39; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 12px; cursor:pointer; }
button:hover { border-color:var(--accent); }
button.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
input[type=text], textarea { width:100%; background:#0f1115; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font-family: Consolas, "D2Coding", monospace; }
textarea { min-height:90px; resize:vertical; }
pre { margin:0; background:#0f1115; border:1px solid var(--line); border-radius:6px; padding:8px; max-height:280px; overflow:auto; font:12px/1.4 Consolas, "D2Coding", monospace; white-space:pre-wrap; word-break:break-all; }
.kv b { color:var(--fg); }
.kv span { color:var(--dim); margin-right:14px; }
.ok { color:var(--ok); } .warn { color:var(--warn); } .err { color:var(--err); }
.log-Error, .log-Exception, .log-error { color:var(--err); } .log-Warning, .log-warn { color:var(--warn); }
img#shot { max-width:100%; border:1px solid var(--line); border-radius:6px; display:none; }
label { color:var(--dim); }
</style>
</head>
<body>
<header>
<h1>WL Pipeline 제어판</h1>
<span id="conn" class="warn">연결 확인 중…</span>
<span class="proj" id="proj"></span>
</header>
<main>
<section class="wide kv">
<h2>상태</h2>
<span>에디터 <b id="ed-status">-</b></span>
<span>Play <b id="ed-play">-</b></span>
<span>컴파일 <b id="ed-compiling">-</b></span>
<span>런타임 서버 <b id="rt-status">-</b></span>
<span>timeScale <b id="ts-now">-</b></span>
</section>
<section>
<h2>Play · 에디터</h2>
<div class="row">
<button class="primary" data-cmd="editor_play">▶ Play</button>
<button data-cmd="editor_pause">⏸ Pause</button>
<button data-cmd="editor_stop">■ Stop</button>
<button id="btn-recompile">⟳ 리컴파일</button>
<button data-cmd="editor_focus">에디터 포커스</button>
<button data-cmd="wl_info">wl_info</button>
<button id="btn-shot">📷 게임 뷰</button>
</div>
<div class="row" style="margin-top:10px">
<label>timeScale</label>
<input type="range" id="ts" min="0" max="3" step="0.05" value="1" style="flex:1">
<b id="ts-val">1.00</b>
<button id="ts-apply">적용</button>
</div>
</section>
<section>
<h2>명령줄 (unity command … 와 동일)</h2>
<div class="row">
<input type="text" id="cmdline" placeholder='예: wl_info · get_time_settings · wl_teleport_player --x 0 --y 1 --z 0' style="flex:1">
<button class="primary" id="run">실행</button>
</div>
<div class="row" style="margin-top:8px">
<label><input type="checkbox" id="rt-target"> 런타임 서버(79xx)로 보내기</label>
<input type="text" id="cmd-search" placeholder="명령 검색 (예: wl_ · scene · hotreload)" style="width:220px">
<button id="btn-list">목록</button>
</div>
</section>
<section>
<h2>C# eval (한 줄 · Play 중 값 확인·변경)</h2>
<textarea id="code" placeholder='예: return UnityEngine.Object.FindFirstObjectByType&lt;WL.Player.PlayerController&gt;().CurrentHp;'></textarea>
<div class="row" style="margin-top:8px"><button class="primary" id="eval">eval 실행</button><span class="warn" style="font-size:12px">여러 줄 코드는 AgentScripts/*.cs + run_script 를 쓴다</span></div>
</section>
<section>
<h2>결과</h2>
<pre id="out">-</pre>
</section>
<section class="wide">
<h2>콘솔 <button id="console-refresh" style="margin-left:8px">갱신</button> <label style="margin-left:8px"><input type="checkbox" id="console-auto" checked> 자동(3초)</label></h2>
<pre id="console">-</pre>
</section>
<section class="wide">
<h2>스크린샷</h2>
<img id="shot" alt="game view">
</section>
</main>
<script>
const $ = s => document.querySelector(s);
let lastPlayMode = null;
async function api(path, body, runtime = false) {
const prefix = runtime ? '/runtime' : '';
const init = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
const r = await fetch(prefix + path, init);
try { return await r.json(); } catch { return { success: false, error: 'non-JSON response', httpStatus: r.status }; }
}
const exec = (commandLine, runtime = false) => api('/api/exec', { commandLine }, runtime);
function show(obj) { $('#out').textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2); }
function findBase64(o) {
if (!o || typeof o !== 'object') return null;
for (const [k, v] of Object.entries(o)) {
if (typeof v === 'string' && v.length > 200 && /^[A-Za-z0-9+/=]+$/.test(v.slice(0, 64))) return v;
if (typeof v === 'object') { const r = findBase64(v); if (r) return r; }
}
return null;
}
async function pollStatus() {
try {
const s = await exec('editor_status');
const r = s.result || {};
$('#conn').textContent = s.success ? '연결됨' : ('오류: ' + (s.error || s.httpStatus));
$('#conn').className = s.success ? 'ok' : 'err';
$('#ed-status').textContent = r.status ?? '-';
$('#ed-play').textContent = r.playMode ?? '-';
$('#ed-compiling').textContent = r.compiling ? '진행 중' : (r.domainReloadInProgress ? '도메인 리로드' : '아님');
lastPlayMode = r.playMode;
} catch (e) { $('#conn').textContent = '프록시 연결 실패'; $('#conn').className = 'err'; }
try {
const d = await api('/panel/descriptor');
$('#proj').textContent = d.project || '';
$('#rt-status').textContent = d.runtime && d.runtime.available ? ('포트 ' + d.runtime.port) : '없음';
} catch {}
try {
const t = await exec('get_time_settings');
const ts = t.result && (t.result.timeScale ?? t.result.TimeScale);
if (ts !== undefined) $('#ts-now').textContent = Number(ts).toFixed(2);
} catch {}
}
async function refreshConsole() {
const res = await exec('console --tail 40');
const entries = (res.result && (res.result.entries || res.result.Entries)) || [];
if (!res.success) { $('#console').textContent = '콘솔 읽기 실패: ' + JSON.stringify(res); return; }
$('#console').innerHTML = entries.map(e => {
const lvl = e.level ?? e.type ?? e.Level ?? e.Type ?? '';
const msg = e.message ?? e.Message ?? JSON.stringify(e);
const ts = e.timestamp ?? e.timestampUtc ?? e.Timestamp ?? '';
return `<span class="log-${lvl}">[${lvl}] ${String(ts).slice(11, 19)} ${escapeHtml(msg)}</span>`;
}).join('\n') || '(비어 있음)';
$('#console').scrollTop = $('#console').scrollHeight;
}
function escapeHtml(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
document.querySelectorAll('button[data-cmd]').forEach(b => b.addEventListener('click', async () => {
show('실행 중: ' + b.dataset.cmd);
show(await exec(b.dataset.cmd));
pollStatus();
}));
$('#btn-recompile').addEventListener('click', async () => {
show(await exec('recompile'));
for (let i = 0; i < 90; i++) {
await new Promise(r => setTimeout(r, 1000));
let st; try { st = await exec('recompile_status'); } catch { continue; }
const s = st.result && st.result.status;
show(st);
if (['completed', 'up_to_date', 'failed'].includes(s)) break;
}
pollStatus();
});
$('#ts').addEventListener('input', () => { $('#ts-val').textContent = Number($('#ts').value).toFixed(2); });
$('#ts-apply').addEventListener('click', async () => {
const v = Number($('#ts').value);
let res = await exec('wl_timescale --scale ' + v);
if (!res.success) res = await api('/api/exec', { command: 'eval', parameters: { code: `UnityEngine.Time.timeScale = ${v}f; return UnityEngine.Time.timeScale;` } });
show(res); pollStatus();
});
$('#run').addEventListener('click', async () => {
const line = $('#cmdline').value.trim(); if (!line) return;
show('실행 중: ' + line);
show(await exec(line, $('#rt-target').checked));
});
$('#cmdline').addEventListener('keydown', e => { if (e.key === 'Enter') $('#run').click(); });
$('#btn-list').addEventListener('click', async () => {
const q = $('#cmd-search').value.trim();
const res = await api('/api/commands?detail=compact' + (q ? '&query=' + encodeURIComponent(q) : ''), undefined, $('#rt-target').checked);
const cmds = res.commands || [];
show(cmds.length ? cmds.map(c => `${c.name}\t${c.description || ''}`).join('\n') : res);
});
$('#eval').addEventListener('click', async () => {
const code = $('#code').value.trim(); if (!code) return;
show('eval 실행 중…');
show(await api('/api/exec', { command: 'eval', parameters: { code, timeout: 10000 } }, $('#rt-target').checked));
});
$('#btn-shot').addEventListener('click', async () => {
const source = lastPlayMode === 'playing' || lastPlayMode === 'paused' ? 'screen' : 'camera';
show('캡처 중…');
const res = await exec(`capture_game_view --source ${source} --max_resolution 900`);
const b64 = findBase64(res);
if (b64) { $('#shot').src = 'data:image/png;base64,' + b64; $('#shot').style.display = 'block'; show({ success: true, source, note: '아래 스크린샷 참조' }); }
else show(res);
});
$('#console-refresh').addEventListener('click', refreshConsole);
setInterval(() => { if ($('#console-auto').checked) refreshConsole(); }, 3000);
setInterval(pollStatus, 3000);
pollStatus(); refreshConsole();
</script>
</body>
</html>