Files
EVEngine/platform/webgpu/shell.html
T

592 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>EVEngine — WebGPU Playground</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background: #0d0e13; color: #d5d9e2;
font: 13px/1.5 system-ui, "Segoe UI", sans-serif;
}
#app { position: absolute; inset: 0; display: flex; flex-direction: column; }
/* Loading overlay */
#loading {
position: absolute; inset: 0; z-index: 50;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: #0d0e13; color: #c8c8d0; font-size: 14px; letter-spacing: 0.05em;
}
#loading .spinner {
width: 36px; height: 36px; border: 3px solid #2a2a34; border-top-color: #5aa9ff;
border-radius: 50%; animation: spin 0.9s linear infinite; margin-bottom: 12px;
}
#loading.failed { color: #ff6b6b; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Toolbar */
#toolbar {
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
padding: 8px 12px; background: #171923; border-bottom: 1px solid #262a38;
}
.brand { font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
.brand span { color: #5aa9ff; margin-left: 6px; font-weight: 500; }
#toolbar select {
background: #10121b; color: #d5d9e2; border: 1px solid #333a4d;
border-radius: 6px; padding: 5px 8px;
}
.btn {
background: #232838; color: #d5d9e2; border: 1px solid #333a4d;
border-radius: 6px; padding: 5px 12px; cursor: pointer; white-space: nowrap;
}
.btn:hover { background: #2b3247; }
.btn.primary { background: #2f6fd0; border-color: #3d7fe4; color: #fff; }
.btn.primary:hover { background: #3a7ce0; }
.btn.danger { background: #3a2a33; border-color: #6a3a4a; color: #ffb0b8; }
.btn:disabled { opacity: 0.45; cursor: default; }
#saveState { margin-left: auto; font-size: 12px; color: #7f8798; white-space: nowrap; }
/* Main split: editor | canvas */
#main { flex: 1 1 auto; display: flex; min-height: 0; }
#editorPane {
flex: 0 0 42%; display: flex; flex-direction: column;
min-width: 220px; background: #10121a;
}
#divider { flex: 0 0 6px; cursor: col-resize; background: #262a38; }
#divider:hover { background: #3a4a6a; }
#canvas { width: 100%; height: 100%; object-fit: contain; display: block; }
#canvas:focus { outline: none; }
#stagePane {
flex: 1 1 auto; min-width: 0; position: relative;
display: flex; align-items: center; justify-content: center; background: #05060a;
}
#stagePane .veil {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #4a5268; font-size: 13px; pointer-events: none; background: #05060a;
}
.paneTitle {
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
padding: 6px 10px; font-size: 12px; color: #7f8798; letter-spacing: 0.03em;
background: #14161f; border-bottom: 1px solid #262a38;
}
.paneTitle b { color: #c9cfdb; font-weight: 600; }
.paneTitle .hint { color: #5d6474; font-weight: 400; }
.paneTitle .spacer { flex: 1; }
/* Code editor: transparent textarea over a highlighted <pre> */
#editor { position: relative; flex: 1 1 auto; min-height: 0; background: #10121a; }
#hl, #code {
position: absolute; inset: 0; margin: 0; padding: 12px 14px; border: 0; outline: 0;
font: 13px/1.55 "Cascadia Code", Consolas, "Courier New", monospace;
tab-size: 4; white-space: pre; overflow: auto;
}
#hl { color: #c8cede; pointer-events: none; z-index: 0; overflow: hidden; }
#code {
background: transparent; color: transparent; caret-color: #e8ecf4;
z-index: 1; resize: none;
}
#code::selection { background: rgba(90, 169, 255, 0.32); color: transparent; }
#code::placeholder { color: #4a5060; }
.tok-c { color: #5d6474; font-style: italic; }
.tok-s { color: #b8d38f; }
.tok-n { color: #e0b26b; }
.tok-k { color: #6ea8ff; }
.tok-o { color: #9aa4b5; }
/* Console + REPL */
#consoleResize {
flex: 0 0 6px; cursor: row-resize; background: #262a38;
}
#consoleResize:hover { background: #3a4a6a; }
#consolePane {
flex: 0 1 auto; display: flex; flex-direction: column;
height: 170px; max-height: 50%; min-height: 72px;
background: #0b0d13; border-top: 1px solid #262a38;
}
#log {
flex: 1 1 auto; overflow: auto; padding: 6px 10px;
font: 12px/1.5 "Cascadia Code", Consolas, monospace;
white-space: pre-wrap; word-break: break-word;
}
#log .out { color: #b9c2d0; }
#log .err { color: #ff7d8a; }
#log .cmd { color: #8ba5ff; }
#log .sys { color: #5d6474; }
#replRow {
flex: 0 0 auto; display: flex; align-items: center; gap: 6px;
border-top: 1px solid #20242f; padding: 5px 10px;
}
#replRow .replPrompt { color: #5aa9ff; font-weight: 600; }
#repl {
flex: 1; background: #10121b; color: #d5d9e2; border: 1px solid #333a4d;
border-radius: 6px; padding: 5px 8px; outline: 0;
font: 12px/1.4 Consolas, monospace;
}
#repl:focus { border-color: #5aa9ff; }
/* Screenshot preview overlay */
#shotOverlay {
position: fixed; inset: 0; z-index: 60; display: none;
align-items: center; justify-content: center;
background: rgba(5, 6, 10, 0.82); cursor: zoom-out;
}
#shotOverlay img { max-width: 92%; max-height: 92%; border: 1px solid #333a4d; }
/* Status bar */
#statusbar {
flex: 0 0 auto; display: flex; gap: 16px; padding: 4px 12px;
font-size: 11px; color: #6f7789; background: #14161f;
border-top: 1px solid #262a38; white-space: nowrap; overflow: hidden;
}
#statusbar .ok { color: #7fd18b; }
#statusbar .bad { color: #ff7d8a; }
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
<span id="status">正在启动 EVEngine…</span>
</div>
<div id="app">
<header id="toolbar">
<div class="brand">EVEngine<span>WebGPU Playground</span></div>
<select id="exampleSel" disabled title="选择示例脚本(载入编辑器,不自动运行)"></select>
<button id="btnPause" class="btn" disabled title="冻结 eve_update,渲染继续">暂停</button>
<button id="btnShot" class="btn" disabled title="保存当前帧为 PNG 并预览">截图</button>
<button id="btnApply" class="btn primary" disabled title="写入 main.nut 并软热重载(状态保留在 __pg)">应用 Ctrl+Enter</button>
<button id="btnRestart" class="btn danger" disabled title="清空 __pg 状态表并重建场景">重置状态</button>
<span id="saveState"></span>
</header>
<div id="main">
<section id="editorPane">
<div class="paneTitle"><b>main.nut</b><span class="hint">编辑 Squirrel 脚本 — Ctrl+Enter 热更新,状态保留在 __pg</span></div>
<div id="editor">
<pre id="hl" aria-hidden="true"></pre>
<textarea id="code" wrap="off" spellcheck="false" autocomplete="off"
autocapitalize="off" autocorrect="off" placeholder="引擎加载中…"></textarea>
</div>
</section>
<div id="divider"></div>
<section id="stagePane">
<div class="veil">WebGPU 画面</div>
<canvas id="canvas" width="800" height="600" tabindex="0"></canvas>
</section>
</div>
<div id="consoleResize" title="拖动调整控制台高度"></div>
<section id="consolePane">
<div class="paneTitle">
<b>控制台</b><span class="hint">引擎 print / 脚本错误 / REPL</span>
<span class="spacer"></span>
<button id="btnClearLog" class="btn" style="padding: 2px 8px">清空</button>
</div>
<div id="log"></div>
<div id="replRow">
<span class="replPrompt">&gt;</span>
<input id="repl" spellcheck="false" autocomplete="off"
placeholder="在运行中的引擎里执行一行 Squirrel,回车运行(如 __pg.frames)">
</div>
</section>
<footer id="statusbar">
<span id="stEngine">engine: 未就绪</span>
<span id="stIsolated">crossOriginIsolated=…</span>
<span id="stAdapter">webgpu=…</span>
<span>键盘输入仅在点击渲染画面后生效</span>
<span>热更新: 开(dofile + eve_reload,__pg 状态保留)</span>
</footer>
</div>
<div id="shotOverlay" title="点击关闭截图预览"><img id="shotImg" alt="screenshot"></div>
<script>
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var ta = $('code'), hl = $('hl'), logEl = $('log');
var engineReady = false;
var PG_KEY = 'eve.playground.main.nut';
var EXAMPLES = [
{ path: 'playground/demos/3d_sphere.nut', label: '3D 旋转球体(默认)' },
{ path: 'playground/demos/2d_sprites.nut', label: '2D 精灵 + 键盘' },
{ path: 'playground/demos/3d_textured.nut', label: '3D 纹理立方体' },
{ path: 'playground/demos/hot_state.nut', label: '热更新状态演示' }
];
function timeStr() {
var d = new Date();
function p(n) { return (n < 10 ? '0' : '') + n; }
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
// ---- console ---------------------------------------------------------
function pgLog(kind, text) {
if (text == null) text = '';
var line = document.createElement('div');
line.className = kind;
line.textContent = String(text);
logEl.appendChild(line);
while (logEl.childNodes.length > 600) logEl.removeChild(logEl.firstChild);
logEl.scrollTop = logEl.scrollHeight;
}
window.pgLog = pgLog; // Module.print / printErr overrides below
$('btnClearLog').addEventListener('click', function () { logEl.innerHTML = ''; });
// ---- Squirrel syntax highlighting ------------------------------------
var SQ_KEYWORDS = {};
('base break case catch class clone const continue default delete do else enum ' +
'extends false for foreach function if in instanceof local null resume return ' +
'static switch this throw true try typeof while yield constructor rawcall ' +
'array table').split(' ').forEach(function (k) { SQ_KEYWORDS[k] = true; });
var TOKEN_RE =
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|0x[0-9a-fA-F]+)|([A-Za-z_]\w*)|(<-|=>|==|!=|<=|>=|\+\+|--|::|\.\.|[-+*/%<>=!&|^~?:]+)/g;
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function highlight(src) {
var out = '', last = 0, m;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(src))) {
out += esc(src.slice(last, m.index));
if (m[1]) out += '<span class="tok-c">' + esc(m[1]) + '</span>';
else if (m[2]) out += '<span class="tok-s">' + esc(m[2]) + '</span>';
else if (m[3]) out += '<span class="tok-n">' + esc(m[3]) + '</span>';
else if (m[4]) out += SQ_KEYWORDS[m[4]] ? '<span class="tok-k">' + m[4] + '</span>' : esc(m[4]);
else out += '<span class="tok-o">' + esc(m[0]) + '</span>';
last = m.index + m[0].length;
}
return out + esc(src.slice(last));
}
function syncEditor() {
hl.innerHTML = highlight(ta.value) + '\n';
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
}
ta.addEventListener('input', syncEditor);
ta.addEventListener('scroll', function () {
hl.scrollTop = ta.scrollTop;
hl.scrollLeft = ta.scrollLeft;
});
ta.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var s = ta.selectionStart, en = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(en);
ta.selectionStart = ta.selectionEnd = s + 4;
syncEditor();
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
applyNow();
}
});
syncEditor();
// ---- editor/canvas divider -------------------------------------------
(function () {
var divider = $('divider'), main = $('main'), pane = $('editorPane');
divider.addEventListener('mousedown', function (e) {
e.preventDefault();
function move(ev) {
var rect = main.getBoundingClientRect();
var frac = (ev.clientX - rect.left) / rect.width;
frac = Math.max(0.25, Math.min(0.7, frac));
pane.style.flex = '0 0 ' + (frac * 100) + '%';
}
function up() {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
}
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
});
})();
// ---- console height (capped) ----------------------------------------
(function () {
var handle = $('consoleResize'), pane = $('consolePane');
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
function move(ev) {
var h = window.innerHeight - ev.clientY;
h = Math.max(72, Math.min(window.innerHeight * 0.5, h));
pane.style.height = h + 'px';
}
function up() {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
}
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
});
})();
// ---- canvas focus: engine keyboard only while the stage is active ----
(function () {
var canvas = $('canvas');
canvas.addEventListener('click', function () { canvas.focus(); });
})();
// ---- JS <-> engine bridge wrappers -----------------------------------
function withCString(str, fn) {
var bytes = Module.lengthBytesUTF8(str) + 1;
var ptr = Module._malloc(bytes);
Module.stringToUTF8(str, ptr, bytes);
try { return fn(ptr); } finally { Module._free(ptr); }
}
function bridgesAvailable() {
return Module && Module._eve_playground_apply &&
Module._eve_playground_eval && Module._eve_playground_read &&
Module._eve_playground_read_bytes && Module._eve_playground_capture &&
Module._eve_playground_readback_status && Module._eve_playground_reset;
}
function pgApply(src) {
if (!bridgesAvailable()) { pgLog('err', '[playground] 桥接函数缺失:eve.js 版本过旧?'); return; }
withCString(src, function (p) { Module._eve_playground_apply(p); });
}
function pgEval(src) {
if (!bridgesAvailable()) { pgLog('err', '[playground] 桥接函数缺失:eve.js 版本过旧?'); return ''; }
return withCString(src, function (p) {
return Module.UTF8ToString(Module._eve_playground_eval(p));
});
}
function pgRead(path) {
if (!bridgesAvailable()) return '';
return withCString(path, function (p) {
return Module.UTF8ToString(Module._eve_playground_read(p));
});
}
function pgSetPaused(on) { Module._eve_playground_set_paused(on ? 1 : 0); }
function pgReset() {
if (!bridgesAvailable()) return;
Module._eve_playground_reset();
}
function pgReadBytes(path, cap) {
if (!bridgesAvailable()) return null;
var ptr = Module._malloc(cap);
try {
var n = withCString(path, function (p) {
return Module._eve_playground_read_bytes(p, ptr, cap);
});
if (!n) return null;
return new Uint8Array(new Uint8Array(Module.HEAPU8.buffer, ptr, n));
} finally { Module._free(ptr); }
}
// ---- actions ---------------------------------------------------------
function saveLocal() {
try { localStorage.setItem(PG_KEY, ta.value); } catch (e) {}
}
function setSave(text) { $('saveState').textContent = text; }
// Which demo the running engine currently executes. Switching to a
// *different* demo resets the scene first (destroy old entities + clear
// __pg); edits to the same script keep hot-reload state.
var appliedDemoPath = null;
var pendingDemoPath = null;
function applyNow() {
if (!engineReady) return;
var isSwitch = pendingDemoPath && pendingDemoPath !== appliedDemoPath;
if (isSwitch) {
pgReset();
appliedDemoPath = pendingDemoPath;
}
saveLocal();
pgApply(ta.value);
if (isSwitch) {
// 热重载只替换脚本、不会自动调用 eve_init;切换 demo 后需要重建场景。
var r = pgEval('if ("eve_init" in getroottable()) { eve_init(); "demo applied"; } else "no eve_init"');
pgLog('sys', '[playground] ' + r);
}
setSave('已应用 ' + timeStr());
}
function restartNow() {
if (!engineReady) return;
// 销毁旧 ECS 实体并清空 __pg → 重新应用脚本 → 重跑 eve_init 重建场景。
pgReset();
pgLog('sys', '[playground] 状态已清空(旧实体已销毁)');
saveLocal();
pgApply(ta.value);
var r2 = pgEval('if ("eve_init" in getroottable()) { eve_init(); "init re-run"; } else "no eve_init"');
pgLog('sys', '[playground] ' + r2);
setSave('已重置状态并应用 ' + timeStr());
}
$('btnApply').addEventListener('click', applyNow);
$('btnRestart').addEventListener('click', restartNow);
$('btnShot').addEventListener('click', screenshotNow);
$('shotOverlay').addEventListener('click', function () { this.style.display = 'none'; });
function screenshotNow() {
if (!engineReady) return;
withCString('screenshot.png', function (p) { Module._eve_playground_capture(p); });
pgLog('sys', '[playground] 正在截图…');
var tries = 0;
var timer = setInterval(function () {
tries++;
var st = Module._eve_playground_readback_status();
if (st === 2 || st === 3 || tries > 40) {
clearInterval(timer);
if (st !== 2) { pgLog('err', '[playground] 截图失败(状态 ' + st + ')'); return; }
var data = pgReadBytes('screenshot.png', 4 * 1024 * 1024);
if (!data || !data.length) { pgLog('err', '[playground] 截图失败(读取 PNG 失败)'); return; }
var url = URL.createObjectURL(new Blob([data], { type: 'image/png' }));
var img = $('shotImg');
img.onload = function () { URL.revokeObjectURL(url); };
img.src = url;
$('shotOverlay').style.display = 'flex';
pgLog('sys', '[playground] 截图完成(' + data.length + ' 字节)');
}
}, 100);
}
var paused = false;
$('btnPause').addEventListener('click', function () {
if (!engineReady) return;
paused = !paused;
pgSetPaused(paused);
$('btnPause').textContent = paused ? '继续' : '暂停';
pgLog('sys', paused
? '[playground] 已暂停(eve_update 冻结,渲染继续)'
: '[playground] 已继续');
});
// REPL
$('repl').addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
var v = this.value.trim();
if (!v || !engineReady) return;
pgLog('cmd', '> ' + v);
pgLog('out', String(pgEval(v)));
this.value = '';
});
// examples dropdown (reads files from the engine VFS)
function fillExamples() {
var sel = $('exampleSel');
sel.innerHTML = '';
EXAMPLES.forEach(function (ex) {
if (ex.path !== 'main.nut' && pgRead(ex.path) === '') return;
var opt = document.createElement('option');
opt.value = ex.path;
opt.textContent = ex.label;
sel.appendChild(opt);
});
sel.disabled = sel.options.length === 0;
}
$('exampleSel').addEventListener('change', function () {
var src = pgRead(this.value);
if (src === '') { pgLog('err', '[playground] 无法读取 ' + this.value); return; }
pendingDemoPath = this.value;
ta.value = src;
syncEditor();
saveLocal();
setSave('已载入 ' + this.value + ' — 按“应用”或 Ctrl+Enter 运行');
});
// ---- engine ready ----------------------------------------------------
function engineUp() {
if (engineReady) return;
engineReady = true;
// 启动时运行的 /game/main.nut 与“3D 旋转球体(默认)”demo 内容一致,
// 所以默认球体选项不视为一次“切换”,避免首次应用就重置场景。
appliedDemoPath = 'playground/demos/3d_sphere.nut';
var veil = document.querySelector('#stagePane .veil');
if (veil) veil.style.display = 'none';
$('stEngine').textContent = 'engine: 已就绪';
$('stEngine').className = 'ok';
$('btnApply').disabled = false;
$('btnRestart').disabled = false;
$('btnPause').disabled = false;
$('btnShot').disabled = false;
// 恢复上次会话:localStorage 优先,否则读 /game/main.nut
var saved = null;
try { saved = localStorage.getItem(PG_KEY); } catch (e) {}
if (saved == null || saved === '') saved = pgRead('main.nut');
if (saved !== '') {
ta.value = saved;
syncEditor();
}
fillExamples();
pgLog('sys', '[playground] 引擎就绪。编辑脚本后按 Ctrl+Enter 热更新(状态保留在 __pg),或点“重置状态”重建场景。');
// 会话恢复:如果本地保存的脚本和当前 main.nut 不同,自动应用
try { saved = localStorage.getItem(PG_KEY); } catch (e) { saved = null; }
if (saved != null && saved !== '' && saved !== pgRead('main.nut')) {
pgApply(saved);
setSave('已恢复上次会话并应用 ' + timeStr());
} else {
setSave('就绪 ' + timeStr());
}
}
window.eveEngineReady = engineUp; // C++ calls this after load.nut finishes
})();
// Module is consumed by the generated Emscripten script in the next block.
var Module = {
canvas: document.getElementById('canvas'),
locateFile: function (path) { return path; },
print: function (text) { if (window.pgLog) window.pgLog('out', text); },
printErr: function (text) { if (window.pgLog) window.pgLog('err', text); },
onRuntimeInitialized: function () {
var st = document.getElementById('status');
if (st) st.textContent = '引擎核心已加载,正在启动…';
}
};
window.hideEVELoading = function () {
var el = document.getElementById('loading');
if (el) el.style.display = 'none';
};
// 镜像引擎侧的 console.log/warn/error(如 GPU 错误)到页面控制台
(function () {
function wrap(method, kind) {
var orig = console[method];
console[method] = function () {
var args = Array.prototype.slice.call(arguments);
try { orig.apply(console, args); } catch (e) {}
var text = args.map(function (a) {
if (typeof a === 'string') return a;
if (a && a.message) return a.message;
try { return JSON.stringify(a); } catch (e2) { return String(a); }
}).join(' ');
if (window.pgLog) window.pgLog(kind, text);
};
}
wrap('log', 'out');
wrap('warn', 'out');
wrap('error', 'err');
})();
// 状态栏诊断:跨源隔离 + WebGPU 适配器(无需 DevTools 即可看到初始化失败原因)
(function () {
var st = document.getElementById('status');
var iso = document.getElementById('stIsolated');
var adp = document.getElementById('stAdapter');
iso.textContent = 'crossOriginIsolated=' + (crossOriginIsolated ? 'yes' : 'no');
if (!crossOriginIsolated) iso.className = 'bad';
if (navigator.gpu) {
navigator.gpu.requestAdapter().then(function (a) {
if (a) {
var info = (a.info && (a.info.vendor || a.info.architecture)) || 'ok';
adp.textContent = 'webgpuAdapter=' + JSON.stringify(info);
adp.className = 'ok';
} else {
adp.textContent = 'webgpuAdapter=null';
adp.className = 'bad';
}
if (st) st.textContent = '引擎就绪';
}).catch(function (e) {
adp.textContent = 'adapterError=' + e.message;
adp.className = 'bad';
});
} else {
adp.textContent = 'webgpu=unsupported(需要 Chrome/Edge 113+ 并启用 WebGPU)';
adp.className = 'bad';
}
})();
</script>
{{{ SCRIPT }}}
</body>
</html>