From f43f4023818bf0395c3c1f2d5764f9006f2184f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 15:59:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=203D=20=E5=8C=96=E8=BA=AB=E5=B8=A7?= =?UTF-8?q?=E8=BD=AE=E6=92=AD=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=88frames=5F?= =?UTF-8?q?url=20=E8=BD=AE=E6=92=AD=20+=20=E7=A9=BA=E5=80=BC=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E9=9D=99=E6=80=81=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- lib/features/profile/avatar_provider.dart | 3 + lib/features/profile/avatar_viewer_page.dart | 1 + lib/shared/widgets/avatar_viewer.dart | 104 ++++++++++++++++--- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/lib/features/profile/avatar_provider.dart b/lib/features/profile/avatar_provider.dart index 4113bd0..87499d8 100644 --- a/lib/features/profile/avatar_provider.dart +++ b/lib/features/profile/avatar_provider.dart @@ -7,6 +7,7 @@ class AvatarState { final int bodyTemplateId; final int skinToneIndex; final String glbUrl; + final String framesUrl; final String buildStatus; // pending / processing / done / failed final String error; @@ -15,6 +16,7 @@ class AvatarState { this.bodyTemplateId = 0, this.skinToneIndex = 0, this.glbUrl = '', + this.framesUrl = '', this.buildStatus = '', this.error = '', }); @@ -35,6 +37,7 @@ class AvatarNotifier extends AsyncNotifier { bodyTemplateId: (data['body_template_id'] as num?)?.toInt() ?? 0, skinToneIndex: (data['skin_tone_index'] as num?)?.toInt() ?? 0, glbUrl: data['glb_url'] as String? ?? '', + framesUrl: data['frames_url'] as String? ?? '', buildStatus: data['build_status'] as String? ?? '', error: data['error'] as String? ?? '', ); diff --git a/lib/features/profile/avatar_viewer_page.dart b/lib/features/profile/avatar_viewer_page.dart index 4c709d7..42702f0 100644 --- a/lib/features/profile/avatar_viewer_page.dart +++ b/lib/features/profile/avatar_viewer_page.dart @@ -23,6 +23,7 @@ class AvatarViewerPage extends ConsumerWidget { children: [ AvatarViewer( glbUrl: a.glbUrl, + framesUrl: a.framesUrl, title: '我的 3D 化身', subtitle: a.built ? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}' diff --git a/lib/shared/widgets/avatar_viewer.dart b/lib/shared/widgets/avatar_viewer.dart index b757589..d39c4db 100644 --- a/lib/shared/widgets/avatar_viewer.dart +++ b/lib/shared/widgets/avatar_viewer.dart @@ -1,32 +1,108 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -/// 3D 化身查看组件(MVP 占位实现) +import '../../core/config/app_config.dart'; + +/// 3D 化身查看组件 /// -/// v2 接入真实 3D 渲染的方式: -/// 1. 服务端提供预烘焙 GLB 资产(/workspace/templates/avatar_f{face}_b{body}_s{skin}.glb) -/// 2. pubspec 引入 three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20 -/// (注意 flutter_gl 0.0.21 在 Flutter 3.44 下无法编译,需锁 0.0.20) -/// 3. 用 flutter_gl 初始化 GL 上下文 + GLTFLoader 加载 GLB + 自动旋转渲染, -/// 参考 three_dart 0.0.16 的 example/lib/webgl_loader_glb.dart -class AvatarViewer extends StatelessWidget { +/// 服务端预渲染帧序列(36 帧绕 Y 轴旋转 PNG)轮播模拟 3D; +/// framesUrl 为空时降级为静态占位。 +class AvatarViewer extends StatefulWidget { final String? glbUrl; + final String? framesUrl; final String title; final String subtitle; final double height; const AvatarViewer({ super.key, - required this.glbUrl, + this.glbUrl, + this.framesUrl, required this.title, required this.subtitle, this.height = 360, }); + @override + State createState() => _AvatarViewerState(); +} + +class _AvatarViewerState extends State { + static const int _frameCount = 36; + + Timer? _timer; + int _frame = 0; + + @override + void initState() { + super.initState(); + if (widget.framesUrl != null && widget.framesUrl!.isNotEmpty) { + _timer = Timer.periodic(const Duration(milliseconds: 120), (_) { + setState(() => _frame = (_frame + 1) % _frameCount); + }); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final framesUrl = widget.framesUrl; + if (framesUrl != null && framesUrl.isNotEmpty) { + final frameUrl = AppConfig.resolveUrl( + '$framesUrl/frame_${_frame.toString().padLeft(3, '0')}.png'); + final scheme = Theme.of(context).colorScheme; + return Container( + height: widget.height, + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + scheme.primaryContainer, + scheme.primary.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Positioned.fill( + child: Image.network( + frameUrl, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _placeholder(context), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + widget.subtitle, + style: const TextStyle( + color: Colors.white, fontSize: 12, shadows: [ + Shadow(color: Colors.black45, blurRadius: 4), + ]), + ), + ), + ], + ), + ); + } + return _placeholder(context); + } + + Widget _placeholder(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Container( - height: height, + height: widget.height, width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( @@ -44,14 +120,14 @@ class AvatarViewer extends StatelessWidget { children: [ Icon(Icons.accessibility_new, size: 72, color: scheme.primary), const SizedBox(height: 12), - Text(title, - style: - const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text(widget.title, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Text( - subtitle.isEmpty ? '3D 渲染接入中(v2)' : subtitle, + widget.subtitle.isEmpty ? '3D 渲染服务未就绪' : widget.subtitle, textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey, fontSize: 12), ),