223 lines
10 KiB
HTML
223 lines
10 KiB
HTML
|
|
<!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<WL.Player.PlayerController>().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 => ({ '&': '&', '<': '<', '>': '>' }[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>
|