zyc 72e7df09cd Initial commit: AR avatar prototype
包含三个子项目:
- 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>
2026-05-12 11:14:10 +08:00

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>