EG/templates/webgl/frontend_demo.html
2026-04-17 14:37:45 +08:00

580 lines
16 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EG WebGL Frontend Demo</title>
<style>
:root {
color-scheme: light;
--bg: #f5efe4;
--panel: rgba(255, 252, 246, 0.95);
--panel-strong: #fffaf0;
--border: rgba(67, 51, 31, 0.18);
--text: #2d241a;
--muted: #72614c;
--accent: #2f7f6d;
--accent-strong: #1e5f52;
--danger: #9a3d2e;
--shadow: 0 20px 50px rgba(63, 41, 19, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(47, 127, 109, 0.12), transparent 32%),
radial-gradient(circle at right center, rgba(223, 138, 87, 0.14), transparent 28%),
linear-gradient(180deg, #f9f4ea 0%, var(--bg) 100%);
}
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 20px;
min-height: 100vh;
padding: 20px;
}
.panel,
.viewer-shell {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.panel {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.row.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
input,
select,
button,
textarea {
width: 100%;
border-radius: 12px;
border: 1px solid var(--border);
padding: 11px 12px;
font: inherit;
color: var(--text);
background: var(--panel-strong);
}
button {
cursor: pointer;
background: var(--accent);
border-color: transparent;
color: #fff;
font-weight: 600;
}
button.secondary {
background: #efe4cf;
color: var(--text);
border-color: var(--border);
}
button.danger {
background: var(--danger);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.status {
padding: 12px 14px;
border-radius: 14px;
background: rgba(47, 127, 109, 0.08);
border: 1px solid rgba(47, 127, 109, 0.15);
color: var(--accent-strong);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
.viewer-shell {
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.viewer-head strong {
font-size: 16px;
}
.viewer-shell iframe {
flex: 1;
width: 100%;
min-height: 70vh;
border: 0;
background: #111;
}
textarea {
min-height: 220px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
background: rgba(47, 127, 109, 0.08);
border-radius: 8px;
padding: 2px 6px;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.viewer-shell iframe {
min-height: 58vh;
}
}
</style>
</head>
<body>
<div class="layout">
<aside class="panel">
<div class="section">
<h1>Frontend Interaction Demo</h1>
<p>这个页面模拟你的前端宿主页,通过 <code>iframe + postMessage</code> 控制导出的 WebGL 页面。</p>
</div>
<div class="section">
<div class="row two">
<button id="handshake-btn">1. Handshake</button>
<button id="refresh-btn" class="secondary">2. Load Nodes</button>
</div>
<div class="status" id="status-box">Waiting for handshake...</div>
</div>
<div class="section">
<div>
<label for="node-select">Controlled Node</label>
<select id="node-select">
<option value="">请先 handshake 再加载节点</option>
</select>
</div>
<div class="hint">选择器优先使用 <code>publicId</code>,避免同名节点冲突。</div>
</div>
<div class="section">
<div class="row two">
<button id="toggle-visible-btn" class="secondary">Toggle Visible</button>
<button id="get-state-btn" class="secondary">Get State</button>
</div>
<div class="row">
<button id="move-left-btn" class="secondary">Move -X</button>
<button id="move-forward-btn" class="secondary">Move +Y</button>
<button id="move-right-btn" class="secondary">Move +X</button>
</div>
<div class="row two">
<button id="rotate-btn" class="secondary">Rotate H +45</button>
<button id="reset-transform-btn" class="secondary">Reset Local</button>
</div>
<div class="hint">这里默认用 Panda 坐标系,本地空间:<code>{ coordSystem: "panda", space: "local" }</code></div>
</div>
<div class="section">
<div>
<label for="clip-input">Animation Clip</label>
<input id="clip-input" value="Idle" placeholder="例如 Idle / Walk / Run" />
</div>
<div class="row">
<button id="play-btn">Play</button>
<button id="pause-btn" class="secondary">Pause</button>
<button id="stop-btn" class="danger">Stop</button>
</div>
<div class="row two">
<button id="speed-up-btn" class="secondary">Speed x1.5</button>
<button id="seek-btn" class="secondary">Seek 0.5s</button>
</div>
</div>
<div class="section">
<label for="log-box">Message Log</label>
<textarea id="log-box" readonly></textarea>
</div>
</aside>
<section class="viewer-shell">
<div class="viewer-head">
<strong>Embedded Viewer</strong>
<span class="hint">iframe src: <code>./index.html</code></span>
</div>
<iframe id="viewer-frame" src="./index.html" title="EG WebGL Viewer"></iframe>
</section>
</div>
<script>
const viewerFrame = document.getElementById("viewer-frame");
const statusBox = document.getElementById("status-box");
const logBox = document.getElementById("log-box");
const nodeSelect = document.getElementById("node-select");
const clipInput = document.getElementById("clip-input");
const pendingReplies = new Map();
let sequence = 0;
let handshakeDone = false;
let readyReceived = false;
let knownNodes = [];
function getTargetOrigin() {
try {
const target = new URL(viewerFrame.getAttribute("src") || "./index.html", window.location.href);
return target.origin && target.origin !== "null" ? target.origin : "*";
} catch (err) {
return "*";
}
}
function nextId(prefix) {
sequence += 1;
return prefix + "-" + String(sequence);
}
function setStatus(text) {
statusBox.textContent = text;
}
function logLine(label, payload) {
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
const stamp = new Date().toLocaleTimeString();
logBox.value = "[" + stamp + "] " + label + "\n" + text + "\n\n" + logBox.value;
}
function postEnvelope(envelope) {
const targetWindow = viewerFrame.contentWindow;
if (!targetWindow) {
throw new Error("Viewer iframe not ready");
}
targetWindow.postMessage(envelope, getTargetOrigin());
}
function sendHandshake() {
const id = nextId("handshake");
return new Promise((resolve, reject) => {
pendingReplies.set(id, { resolve, reject });
postEnvelope({
source: "eg-frontend",
type: "handshake",
id,
});
}).then((result) => {
handshakeDone = true;
setStatus("Handshake success. ready=" + String(!!result.ready) + ", apiVersion=" + String(result.apiVersion));
logLine("handshake ok", result);
return result;
});
}
function sendCommand(command, payload) {
if (!handshakeDone) {
return Promise.reject(new Error("Please handshake first"));
}
const id = nextId("cmd");
return new Promise((resolve, reject) => {
pendingReplies.set(id, { resolve, reject });
postEnvelope({
source: "eg-frontend",
type: "command",
id,
command,
payload,
});
});
}
function getSelectedSelector() {
const publicId = nodeSelect.value;
if (!publicId) {
throw new Error("Please choose a node first");
}
return { publicId };
}
function fillNodeSelect(nodes) {
knownNodes = Array.isArray(nodes) ? nodes.slice() : [];
nodeSelect.innerHTML = "";
if (knownNodes.length === 0) {
const option = document.createElement("option");
option.value = "";
option.textContent = "没有可控制节点";
nodeSelect.appendChild(option);
return;
}
for (const node of knownNodes) {
const option = document.createElement("option");
option.value = node.publicId || node.id || "";
option.textContent = (node.name || "(unnamed)") + " [" + (node.kind || "node") + "]";
nodeSelect.appendChild(option);
}
}
async function refreshNodes() {
const nodes = await sendCommand("nodes.list");
fillNodeSelect(nodes);
setStatus("Loaded " + String(nodes.length) + " nodes");
logLine("nodes.list", nodes);
return nodes;
}
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.source !== "eg-webgl") {
return;
}
if (data.type === "response") {
const pending = pendingReplies.get(data.replyTo);
if (!pending) {
return;
}
pendingReplies.delete(data.replyTo);
if (data.ok) {
pending.resolve(data.result);
} else {
pending.reject(data.error || { code: "unknown_error", message: "Unknown error" });
}
return;
}
if (data.type === "event") {
logLine("event:" + data.event, data.payload);
if (data.event === "ready") {
readyReceived = true;
setStatus("Viewer ready. nodeCount=" + String(data.payload?.nodeCount || 0));
fillNodeSelect(data.payload?.nodes || []);
}
}
});
async function runAction(label, action) {
try {
const result = await action();
if (result !== undefined) {
logLine(label, result);
}
return result;
} catch (error) {
const payload = error && typeof error === "object" ? error : { message: String(error) };
logLine(label + " error", payload);
setStatus("Last error: " + String(payload.message || payload.code || error));
throw error;
}
}
document.getElementById("handshake-btn").addEventListener("click", () => {
runAction("handshake", async () => {
const result = await sendHandshake();
if (result.ready && !readyReceived) {
await refreshNodes();
}
return result;
});
});
document.getElementById("refresh-btn").addEventListener("click", () => {
runAction("nodes.list", refreshNodes);
});
document.getElementById("get-state-btn").addEventListener("click", () => {
runAction("nodes.getState", async () => {
return sendCommand("nodes.getState", {
selector: getSelectedSelector(),
});
});
});
document.getElementById("toggle-visible-btn").addEventListener("click", () => {
runAction("nodes.setVisible", async () => {
const selector = getSelectedSelector();
const state = await sendCommand("nodes.getState", { selector });
return sendCommand("nodes.setVisible", {
selector,
visible: !state.visible,
});
});
});
document.getElementById("move-left-btn").addEventListener("click", () => {
runAction("nodes.translate(-x)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: -1, y: 0, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("move-forward-btn").addEventListener("click", () => {
runAction("nodes.translate(+y)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: 0, y: 1, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("move-right-btn").addEventListener("click", () => {
runAction("nodes.translate(+x)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: 1, y: 0, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("rotate-btn").addEventListener("click", () => {
runAction("nodes.setTransform(rotation)", async () => {
return sendCommand("nodes.setTransform", {
selector: getSelectedSelector(),
patch: {
rotation: { h: 45, p: 0, r: 0 },
},
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("reset-transform-btn").addEventListener("click", () => {
runAction("nodes.setTransform(reset)", async () => {
return sendCommand("nodes.setTransform", {
selector: getSelectedSelector(),
patch: {
position: { x: 0, y: 0, z: 0 },
rotation: { h: 0, p: 0, r: 0 },
scale: { x: 1, y: 1, z: 1 },
},
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("play-btn").addEventListener("click", () => {
runAction("animation.play", async () => {
return sendCommand("animation.play", {
selector: getSelectedSelector(),
options: {
clip: clipInput.value.trim(),
loop: true,
restart: true,
},
});
});
});
document.getElementById("pause-btn").addEventListener("click", () => {
runAction("animation.pause", async () => {
return sendCommand("animation.pause", {
selector: getSelectedSelector(),
});
});
});
document.getElementById("stop-btn").addEventListener("click", () => {
runAction("animation.stop", async () => {
return sendCommand("animation.stop", {
selector: getSelectedSelector(),
options: { reset: true },
});
});
});
document.getElementById("speed-up-btn").addEventListener("click", () => {
runAction("animation.setSpeed", async () => {
return sendCommand("animation.setSpeed", {
selector: getSelectedSelector(),
speed: 1.5,
});
});
});
document.getElementById("seek-btn").addEventListener("click", () => {
runAction("animation.seek", async () => {
return sendCommand("animation.seek", {
selector: getSelectedSelector(),
time: 0.5,
});
});
});
viewerFrame.addEventListener("load", () => {
setStatus("Viewer iframe loaded. Click Handshake to start.");
});
</script>
</body>
</html>