580 lines
16 KiB
HTML
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>
|