包含三个子项目: - avatar-h5-renderer: Live2D Cubism 4 H5 渲染器 (Vite + TS) - avatar_flutter_app: Flutter 容器 App (打包 H5 进 WebView) - gif-export: puppeteer 导出 32 个动作的透明 GIF (供 ESP32 圆屏播放) 模型资源: Haru, Natori (含贴图、moc3、motions, expressions) 设计文档: AI驱动虚拟形象渲染方案_v5.1.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
7.7 KiB
HTML
270 lines
7.7 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
|
<title>Avatar H5 Renderer (PoC)</title>
|
|
<style>
|
|
html, body {
|
|
overflow: hidden;
|
|
margin: 0;
|
|
height: 100%;
|
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
background: #1a1a1f;
|
|
}
|
|
body {
|
|
background-image: url('/back_class_normal.png');
|
|
background-size: cover;
|
|
background-position: center;
|
|
background-repeat: no-repeat;
|
|
}
|
|
html {
|
|
overscroll-behavior-x: none;
|
|
touch-action: none;
|
|
}
|
|
body {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
}
|
|
body > canvas {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: block;
|
|
}
|
|
|
|
/* 调试面板 */
|
|
#debug-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
width: 320px;
|
|
max-height: 100vh;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
box-sizing: border-box;
|
|
background: rgba(15, 15, 20, 0.85);
|
|
backdrop-filter: blur(10px);
|
|
color: #e5e5e5;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
border-left: 1px solid #333;
|
|
z-index: 999;
|
|
}
|
|
#debug-panel h3 {
|
|
margin: 0 0 8px;
|
|
font-size: 14px;
|
|
color: #80c0ff;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
#debug-panel section {
|
|
margin-bottom: 14px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid #2a2a2f;
|
|
}
|
|
#debug-panel section:last-child { border-bottom: none; }
|
|
#debug-panel .row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
#debug-panel button {
|
|
padding: 6px 10px;
|
|
border: 1px solid #444;
|
|
background: #222;
|
|
color: #ddd;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.12s;
|
|
}
|
|
#debug-panel button:hover {
|
|
background: #2d4a6b;
|
|
border-color: #5588cc;
|
|
color: #fff;
|
|
}
|
|
#debug-panel button.primary {
|
|
background: #2766b6;
|
|
border-color: #4488dd;
|
|
color: #fff;
|
|
}
|
|
#debug-panel button.primary:hover {
|
|
background: #3a85d6;
|
|
}
|
|
#debug-panel input[type=range] {
|
|
width: 100%;
|
|
margin-top: 6px;
|
|
}
|
|
#debug-panel .value {
|
|
color: #80c0ff;
|
|
font-family: ui-monospace, Menlo, monospace;
|
|
}
|
|
.hint {
|
|
font-size: 11px;
|
|
color: #888;
|
|
margin-top: 6px;
|
|
}
|
|
code {
|
|
color: #80c0ff;
|
|
font-family: ui-monospace, Menlo, monospace;
|
|
}
|
|
</style>
|
|
<!-- Live2D Cubism Core -->
|
|
<script src="/Core/live2dcubismcore.js"></script>
|
|
<!-- App entry -->
|
|
<script src="./src/main.ts" type="module"></script>
|
|
</head>
|
|
<body>
|
|
<div id="debug-panel">
|
|
<section>
|
|
<h3>角色状态</h3>
|
|
<div class="row">
|
|
<button data-act="state" data-arg="idle">idle 待机</button>
|
|
<button data-act="state" data-arg="listening">listening 倾听</button>
|
|
<button data-act="state" data-arg="thinking">thinking 思考</button>
|
|
<button data-act="state" data-arg="speaking">speaking 说话</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>表情切换</h3>
|
|
<div class="row" id="expression-buttons">
|
|
<span class="hint">加载中...</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>动作播放</h3>
|
|
<div class="row" id="motion-buttons">
|
|
<span class="hint">加载中...</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>嘴型驱动 <span class="value" id="mouth-value">0.00</span></h3>
|
|
<input type="range" id="mouth-slider" min="0" max="100" value="0">
|
|
<div class="row" style="margin-top:6px">
|
|
<button data-act="mouth" data-arg="0">闭</button>
|
|
<button data-act="mouth" data-arg="0.5">半开</button>
|
|
<button data-act="mouth" data-arg="1">全开</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>语义动作</h3>
|
|
<div class="row" id="action-buttons"></div>
|
|
<div class="hint">对应未来 LLM Function Call 触发</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>跳舞</h3>
|
|
<div class="row">
|
|
<button class="primary" data-act="dance-start">💃 开始跳舞</button>
|
|
<button data-act="dance-stop">停止</button>
|
|
</div>
|
|
<div class="hint">程序化驱动身体参数 + 循环切 Dance motion</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>事件模拟</h3>
|
|
<div class="row">
|
|
<button class="primary" data-act="mock-conversation">▶ 播放模拟对话</button>
|
|
</div>
|
|
<div class="hint">listening → thinking → 微笑+说话 → idle</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>Console API</h3>
|
|
<div class="hint">
|
|
浏览器控制台可直接调:<br>
|
|
<code>avatar.setExpression("Smile")</code><br>
|
|
<code>avatar.playMotion("Idle", 0)</code><br>
|
|
<code>avatar.setMouthOpen(0.8)</code><br>
|
|
<code>avatar.listExpressions()</code><br>
|
|
<code>avatar.listMotions()</code>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script type="module">
|
|
function waitReady() {
|
|
return new Promise((resolve) => {
|
|
const check = () => {
|
|
if (window.avatar) {
|
|
const exps = window.avatar.listExpressions();
|
|
const mots = window.avatar.listMotions();
|
|
if (exps.length > 0 || Object.keys(mots).length > 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
}
|
|
setTimeout(check, 150);
|
|
};
|
|
check();
|
|
});
|
|
}
|
|
|
|
waitReady().then(() => {
|
|
const expBox = document.getElementById('expression-buttons');
|
|
expBox.innerHTML = '';
|
|
window.avatar.listExpressions().forEach((name) => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = name;
|
|
btn.onclick = () => window.avatar.setExpression(name);
|
|
expBox.appendChild(btn);
|
|
});
|
|
|
|
const motionBox = document.getElementById('motion-buttons');
|
|
motionBox.innerHTML = '';
|
|
const motions = window.avatar.listMotions();
|
|
Object.entries(motions).forEach(([group, count]) => {
|
|
for (let i = 0; i < count; i++) {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = `${group}#${i}`;
|
|
btn.onclick = () => window.avatar.playMotion(group, i);
|
|
motionBox.appendChild(btn);
|
|
}
|
|
});
|
|
|
|
const actionBox = document.getElementById('action-buttons');
|
|
actionBox.innerHTML = '';
|
|
window.avatar.listActions().forEach((name) => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = name;
|
|
btn.onclick = () => window.avatar.playAction(name);
|
|
actionBox.appendChild(btn);
|
|
});
|
|
|
|
console.log('[debug-panel] ready. avatar API:', window.avatar);
|
|
});
|
|
|
|
const slider = document.getElementById('mouth-slider');
|
|
const mouthVal = document.getElementById('mouth-value');
|
|
slider.addEventListener('input', () => {
|
|
const v = slider.value / 100;
|
|
mouthVal.textContent = v.toFixed(2);
|
|
window.avatar?.setMouthOpen(v);
|
|
});
|
|
|
|
document.querySelectorAll('[data-act]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const act = btn.dataset.act;
|
|
const arg = btn.dataset.arg;
|
|
if (act === 'state') window.avatar?.setState(arg);
|
|
else if (act === 'mouth') {
|
|
const v = parseFloat(arg);
|
|
window.avatar?.setMouthOpen(v);
|
|
slider.value = v * 100;
|
|
mouthVal.textContent = v.toFixed(2);
|
|
} else if (act === 'mock-conversation') {
|
|
window.avatar?.playMockConversation();
|
|
} else if (act === 'dance-start') {
|
|
window.avatar?.startDance();
|
|
} else if (act === 'dance-stop') {
|
|
window.avatar?.stopDance();
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|