包含三个子项目: - 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>
98 lines
3.2 KiB
Dart
98 lines
3.2 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||
|
||
/// Flutter → H5 桥接层。
|
||
///
|
||
/// 把 [window.avatar] 暴露的 JS API 包装成 Dart 方法。
|
||
/// 内部只通过 [evaluateJavascript] 调用,未来切到 Cubism Native 时
|
||
/// 实现这一层的另一个版本即可,UI 完全无需改动。
|
||
class AvatarBridge {
|
||
final InAppWebViewController _ctrl;
|
||
|
||
AvatarBridge(this._ctrl);
|
||
|
||
/// 等待 H5 端的 window.avatar 注册完成且模型加载到 expression/motion 可用
|
||
Future<void> waitReady({Duration timeout = const Duration(seconds: 15)}) async {
|
||
final deadline = DateTime.now().add(timeout);
|
||
while (DateTime.now().isBefore(deadline)) {
|
||
final result = await _ctrl.evaluateJavascript(source: '''
|
||
(function() {
|
||
if (!window.avatar) return false;
|
||
const exps = window.avatar.listExpressions();
|
||
const mots = window.avatar.listMotions();
|
||
return exps.length > 0 || Object.keys(mots).length > 0;
|
||
})()
|
||
''');
|
||
if (result == true) return;
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
if (kDebugMode) {
|
||
debugPrint('[AvatarBridge] waitReady timed out');
|
||
}
|
||
}
|
||
|
||
Future<void> setState(String state) => _eval('avatar.setState("$state")');
|
||
|
||
Future<void> setExpression(String name) =>
|
||
_eval('avatar.setExpression("$name")');
|
||
|
||
Future<void> playMotion(String group, int index) =>
|
||
_eval('avatar.playMotion("$group", $index)');
|
||
|
||
Future<void> setMouthOpen(double value) =>
|
||
_eval('avatar.setMouthOpen($value)');
|
||
|
||
Future<void> playAction(String name) =>
|
||
_eval('avatar.playAction("$name")');
|
||
|
||
Future<void> startDance() => _eval('avatar.startDance()');
|
||
|
||
Future<void> stopDance() => _eval('avatar.stopDance()');
|
||
|
||
Future<void> playMockConversation() => _eval('avatar.playMockConversation()');
|
||
|
||
/// 拉一次资产清单(表情名、动作分组、语义动作列表)
|
||
Future<AvatarManifest> fetchManifest() async {
|
||
final raw = await _ctrl.evaluateJavascript(source: '''
|
||
JSON.stringify({
|
||
expressions: avatar.listExpressions(),
|
||
motions: avatar.listMotions(),
|
||
actions: avatar.listActions(),
|
||
})
|
||
''');
|
||
if (raw is! String) {
|
||
return const AvatarManifest(expressions: [], motions: {}, actions: []);
|
||
}
|
||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||
final expressions =
|
||
(json['expressions'] as List).map((e) => e as String).toList();
|
||
final motions = (json['motions'] as Map<String, dynamic>).map(
|
||
(k, v) => MapEntry(k, v as int),
|
||
);
|
||
final actions =
|
||
(json['actions'] as List).map((e) => e as String).toList();
|
||
return AvatarManifest(
|
||
expressions: expressions,
|
||
motions: motions,
|
||
actions: actions,
|
||
);
|
||
}
|
||
|
||
Future<dynamic> _eval(String source) => _ctrl.evaluateJavascript(source: source);
|
||
}
|
||
|
||
/// 当前角色资产清单(表情名 + motion 分组 + 语义动作)
|
||
class AvatarManifest {
|
||
final List<String> expressions;
|
||
final Map<String, int> motions;
|
||
final List<String> actions;
|
||
const AvatarManifest({
|
||
required this.expressions,
|
||
required this.motions,
|
||
required this.actions,
|
||
});
|
||
}
|