Files
xianyan/lib/shared/widgets/appbar_character_sprite.dart
2026-05-22 02:04:46 +08:00

1446 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言APP — AppBar角色动画精灵
// 创建时间: 2026-05-20
// 更新时间: 2026-05-22
// 作用: AppBar左侧互动角色支持4种造型/7种部件动画/6种手势交互
// 上次更新: 集成PerformanceOrchestrator前后台感知+_eyeOffset改ValueNotifier
// ============================================================
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:xianyan/core/services/performance/performance_orchestrator.dart';
import 'package:xianyan/features/home/providers/character_mood_provider.dart';
enum CharacterExpression {
idle,
blink,
smile,
surprise,
wink,
pout,
love,
tickle,
lookRight,
think,
dizzy,
worried,
speaking,
}
class AppBarCharacterSprite extends StatefulWidget {
const AppBarCharacterSprite({
super.key,
required this.characterId,
this.animationIntensity = 1.0,
this.mood,
this.expression,
this.onTap,
this.onDoubleTap,
this.size = 48.0,
});
final String characterId;
final double animationIntensity;
final CharacterMood? mood;
final CharacterExpression? expression;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final double size;
@override
State<AppBarCharacterSprite> createState() => AppBarCharacterSpriteState();
}
class AppBarCharacterSpriteState extends State<AppBarCharacterSprite>
with TickerProviderStateMixin {
late AnimationController _bounceController;
late AnimationController _earController;
late AnimationController _noseController;
late AnimationController _cheekController;
late AnimationController _expressionController;
late AnimationController _idleController;
CharacterExpression _currentExpression = CharacterExpression.idle;
Timer? _expressionResetTimer;
final ValueNotifier<Offset> _eyeOffsetNotifier = ValueNotifier(Offset.zero);
bool _isLongPressing = false;
bool _isAppForeground = true;
double _df(double v) => v <= 0 ? 1.0 : (v > 1.3 ? 1.3 : v);
@override
void initState() {
super.initState();
final f = _df(widget.animationIntensity);
_bounceController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (500 * f).round()),
);
_earController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (400 * f).round()),
);
_noseController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (300 * f).round()),
);
_cheekController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (500 * f).round()),
);
_expressionController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (300 * f).round()),
);
_idleController = AnimationController(
vsync: this,
duration: Duration(milliseconds: (4000 / f).round()),
);
if (widget.animationIntensity > 0.0 && _isAppForeground)
_idleController.repeat(reverse: true);
PerformanceOrchestrator.instance.onForeground(_onForeground);
PerformanceOrchestrator.instance.onBackground(_onBackground);
}
@override
void didUpdateWidget(AppBarCharacterSprite oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.expression != oldWidget.expression &&
widget.expression != null) {
_triggerExpression(widget.expression!);
}
if (widget.animationIntensity == oldWidget.animationIntensity) return;
final f = _df(widget.animationIntensity);
_bounceController.duration = Duration(milliseconds: (500 * f).round());
_earController.duration = Duration(milliseconds: (400 * f).round());
_noseController.duration = Duration(milliseconds: (300 * f).round());
_cheekController.duration = Duration(milliseconds: (500 * f).round());
_expressionController.duration = Duration(milliseconds: (300 * f).round());
_idleController.duration = Duration(milliseconds: (4000 / f).round());
if (widget.animationIntensity <= 0.0) {
_idleController.stop();
} else if (!_idleController.isAnimating && _isAppForeground) {
_idleController.repeat(reverse: true);
}
}
@override
void dispose() {
_expressionResetTimer?.cancel();
PerformanceOrchestrator.instance.removeCallbacks(_onForeground);
PerformanceOrchestrator.instance.removeCallbacks(_onBackground);
_bounceController.dispose();
_earController.dispose();
_noseController.dispose();
_cheekController.dispose();
_expressionController.dispose();
_idleController.dispose();
_eyeOffsetNotifier.dispose();
super.dispose();
}
void _onForeground() {
_isAppForeground = true;
if (widget.animationIntensity > 0.0 && !_idleController.isAnimating) {
_idleController.repeat(reverse: true);
}
}
void _onBackground() {
_isAppForeground = false;
_idleController.stop();
}
void _triggerExpression(CharacterExpression expr) {
_expressionResetTimer?.cancel();
_currentExpression = expr;
switch (expr) {
case CharacterExpression.idle:
_expressionController.reverse();
_earController.reverse();
_noseController.reverse();
_cheekController.reverse();
_bounceController.reverse();
case CharacterExpression.blink:
_expressionController.forward(from: 0.0);
_scheduleReset(600);
case CharacterExpression.smile:
_expressionController.forward(from: 0.0);
_earController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_bounceController.forward(from: 0.0);
_scheduleReset(1500);
case CharacterExpression.surprise:
_expressionController.forward(from: 0.0);
_earController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_bounceController.forward(from: 0.0);
_scheduleReset(1200);
case CharacterExpression.wink:
_expressionController.forward(from: 0.0);
_earController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
_scheduleReset(1500);
case CharacterExpression.pout:
_expressionController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
_scheduleReset(1500);
case CharacterExpression.love:
_expressionController.forward(from: 0.0);
_earController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
_bounceController.forward(from: 0.0);
_scheduleReset(2000);
case CharacterExpression.tickle:
_expressionController.forward(from: 0.0);
_earController.repeat(reverse: true);
_noseController.repeat(reverse: true);
_cheekController.repeat(reverse: true);
_bounceController.repeat(reverse: true);
case CharacterExpression.lookRight:
_expressionController.forward(from: 0.0);
_earController.forward(from: 0.0);
_scheduleReset(1200);
case CharacterExpression.think:
_expressionController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
_scheduleReset(1500);
case CharacterExpression.dizzy:
_expressionController.forward(from: 0.0);
_earController.repeat(reverse: true);
_cheekController.forward(from: 0.0);
_bounceController.repeat(reverse: true);
_scheduleReset(2000);
case CharacterExpression.worried:
_expressionController.forward(from: 0.0);
_noseController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
_scheduleReset(2000);
case CharacterExpression.speaking:
_expressionController.repeat(reverse: true);
_earController.forward(from: 0.0);
_cheekController.forward(from: 0.0);
}
}
void _scheduleReset(int millis) {
_expressionResetTimer = Timer(Duration(milliseconds: millis), () {
if (mounted && !_isLongPressing)
_triggerExpression(CharacterExpression.idle);
});
}
void _handleTap() {
if (widget.animationIntensity <= 0.0) return;
const expressions = [
CharacterExpression.blink,
CharacterExpression.smile,
CharacterExpression.surprise,
CharacterExpression.wink,
CharacterExpression.pout,
];
_triggerExpression(expressions[math.Random().nextInt(expressions.length)]);
widget.onTap?.call();
}
void _handleDoubleTap() {
if (widget.animationIntensity <= 0.0) return;
_triggerExpression(CharacterExpression.love);
widget.onDoubleTap?.call();
}
void _handleLongPressStart() {
if (widget.animationIntensity <= 0.0) return;
_isLongPressing = true;
_triggerExpression(CharacterExpression.tickle);
}
void _handleLongPressEnd() {
_isLongPressing = false;
_earController.stop();
_noseController.stop();
_cheekController.stop();
_bounceController.stop();
_triggerExpression(CharacterExpression.idle);
}
void lookAtTitle() {
if (widget.animationIntensity <= 0.0) return;
_eyeOffsetNotifier.value = const Offset(3.0, 0.0);
_triggerExpression(CharacterExpression.lookRight);
Future.delayed(const Duration(milliseconds: 1200), () {
if (mounted) _eyeOffsetNotifier.value = Offset.zero;
});
}
void triggerExpression(CharacterExpression expr) {
if (widget.animationIntensity <= 0.0) return;
_triggerExpression(expr);
}
double _getBounceScale() =>
1.0 + (_bounceController.value * 0.15 - 0.05) * widget.animationIntensity;
double _getBounceRotation() =>
(_bounceController.value * 0.16 - 0.08) * widget.animationIntensity;
@override
Widget build(BuildContext context) {
return Listener(
onPointerMove: (event) {
if (widget.animationIntensity <= 0.0) return;
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final center = renderBox.localToGlobal(
Offset(renderBox.size.width / 2, renderBox.size.height / 2),
);
final delta = event.position - center;
const maxOffset = 3.0;
final distance = math.sqrt(delta.dx * delta.dx + delta.dy * delta.dy);
if (distance < 1.0) {
_eyeOffsetNotifier.value = Offset.zero;
} else {
final factor = (distance / 200.0).clamp(0.0, 1.0) * maxOffset;
_eyeOffsetNotifier.value = Offset(
(delta.dx / distance) * factor,
(delta.dy / distance) * factor,
);
}
},
child: GestureDetector(
onTap: _handleTap,
onDoubleTap: _handleDoubleTap,
onLongPress: _handleLongPressStart,
onLongPressEnd: (_) => _handleLongPressEnd(),
child: AnimatedBuilder(
animation: Listenable.merge([
_bounceController,
_earController,
_noseController,
_cheekController,
_expressionController,
_idleController,
_eyeOffsetNotifier,
]),
builder: (context, _) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Transform.scale(
scale: _getBounceScale().clamp(0.8, 1.15),
child: Transform.rotate(
angle: _getBounceRotation().clamp(-0.08, 0.08),
child: CustomPaint(
size: Size(widget.size, widget.size),
painter: _AppBarCharacterPainter(
characterId: widget.characterId,
expression: _currentExpression,
expressionProgress: _expressionController.value,
earProgress: _earController.value,
noseProgress: _noseController.value,
cheekProgress: _cheekController.value,
bounceScale: _getBounceScale(),
bounceRotation: _getBounceRotation(),
eyeOffset: _eyeOffsetNotifier.value,
animationIntensity: widget.animationIntensity,
idleProgress: _idleController.value,
mood: widget.mood,
),
),
),
),
);
},
),
),
);
}
}
// ============================================================
// CustomPainter — 3D质感角色绘制
// ============================================================
class _AppBarCharacterPainter extends CustomPainter {
_AppBarCharacterPainter({
required this.characterId,
required this.expression,
required this.expressionProgress,
required this.earProgress,
required this.noseProgress,
required this.cheekProgress,
required this.bounceScale,
required this.bounceRotation,
required this.eyeOffset,
required this.animationIntensity,
required this.idleProgress,
this.mood,
});
final String characterId;
final CharacterExpression expression;
final double expressionProgress;
final double earProgress;
final double noseProgress;
final double cheekProgress;
final double bounceScale;
final double bounceRotation;
final Offset eyeOffset;
final double animationIntensity;
final double idleProgress;
final CharacterMood? mood;
static const _headRect = Rect.fromLTWH(7, 12, 34, 30);
@override
void paint(Canvas canvas, Size size) {
final s = size.width / 48.0;
canvas.save();
canvas.scale(s, s);
switch (characterId) {
case 'cat':
_paintCatHead(canvas);
case 'dog':
_paintDogHead(canvas);
case 'boy':
_paintBoyHead(canvas);
case 'girl':
_paintGirlHead(canvas);
default:
_paintCatHead(canvas);
}
canvas.restore();
}
// ============================================================
// 共用头部绘制
// ============================================================
void _paintHeadBase(Canvas canvas, List<Color> colors) {
_paintShadow(canvas, 24, 29, 17);
final headPaint = Paint()
..shader = ui.Gradient.radial(const Offset(20, 22), 20, colors, [
0.0,
0.6,
1.0,
])
..isAntiAlias = true;
canvas.drawOval(_headRect, headPaint);
_paintHighlight(canvas, 20, 20, 9, 5, -12);
_paintGroundShadow(canvas);
}
void _paintGroundShadow(Canvas canvas) {
canvas.drawOval(
Rect.fromCenter(center: const Offset(24, 44), width: 24, height: 6),
Paint()
..color = const Color(0xFF000000).withValues(alpha: 0.12)
..maskFilter = const ui.MaskFilter.blur(ui.BlurStyle.normal, 3)
..isAntiAlias = true,
);
}
void _paintShadow(Canvas canvas, double cx, double cy, double rx) {
canvas.drawOval(
Rect.fromCenter(
center: Offset(cx, cy + 2),
width: rx * 2,
height: rx * 1.8,
),
Paint()
..color = const Color(0xFF000000).withValues(alpha: 0.25)
..maskFilter = const ui.MaskFilter.blur(ui.BlurStyle.normal, 4.5)
..isAntiAlias = true,
);
}
void _paintHighlight(
Canvas canvas,
double cx,
double cy,
double rx,
double ry,
double angleDeg,
) {
canvas.save();
canvas.translate(cx, cy);
canvas.rotate(angleDeg * math.pi / 180);
canvas.translate(-cx, -cy);
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy), width: rx * 2, height: ry * 2),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.12)
..isAntiAlias = true,
);
canvas.restore();
}
// ============================================================
// 猫咪造型
// ============================================================
void _paintCatHead(Canvas canvas) {
_paintHeadBase(canvas, [
const Color(0xFFFFE0C0),
const Color(0xFFFFD4A8),
const Color(0xFFF0B070),
]);
_paintCatEars(canvas);
_paintEyes(canvas, 18, 26, 30, 26);
_paintWorriedEyebrows(canvas);
_paintNose(
canvas,
24,
29.5,
4,
2.6,
const Color(0xFFF5C4A0),
const Color(0xFFE8A060),
0.3,
);
_paintNoseHighlight(canvas, 22.7, 28.7, 1.6, 1.0, 0.25);
_paintMouth(canvas, 24, 32);
_paintCheeks(canvas, 13, 30, 35, 30);
_paintCatWhiskers(canvas);
}
void _paintCatEars(Canvas canvas) {
final angle = (earProgress * 0.14 - 0.07) * animationIntensity;
final earPaint = Paint()
..shader = ui.Gradient.linear(const Offset(7, 3), const Offset(18, 16), [
const Color(0xFFFFD4A8),
const Color(0xFFE8A560),
])
..isAntiAlias = true;
final innerPaint = Paint()
..color = const Color(0xFFFFB0B0).withValues(alpha: 0.5)
..isAntiAlias = true;
canvas.save();
canvas.translate(14, 16);
canvas.rotate(angle);
canvas.translate(-14, -16);
final le = Path()
..moveTo(11, 16)
..cubicTo(7, 3, 18, 5, 18, 13)
..close();
canvas.drawPath(le, earPaint);
final li = Path()
..moveTo(12, 14)
..cubicTo(10, 6, 17, 8, 16, 13)
..close();
canvas.drawPath(li, innerPaint);
canvas.restore();
canvas.save();
canvas.translate(34, 16);
canvas.rotate(-angle);
canvas.translate(-34, -16);
final re = Path()
..moveTo(37, 16)
..cubicTo(41, 3, 30, 5, 30, 13)
..close();
canvas.drawPath(re, earPaint);
final ri = Path()
..moveTo(36, 14)
..cubicTo(38, 6, 31, 8, 32, 13)
..close();
canvas.drawPath(ri, innerPaint);
canvas.restore();
}
void _paintCatWhiskers(Canvas canvas) {
final wa = (earProgress * 0.09 - 0.045) * animationIntensity;
final wp = Paint()
..color = const Color(0xFF8B7355).withValues(alpha: 0.35)
..strokeWidth = 0.6
..isAntiAlias = true;
canvas.save();
canvas.translate(14, 30);
canvas.rotate(wa);
canvas.translate(-14, -30);
canvas.drawLine(const Offset(14, 28), const Offset(4, 27), wp);
canvas.drawLine(const Offset(14, 30), const Offset(3, 30), wp);
canvas.drawLine(const Offset(14, 32), const Offset(4, 33), wp);
canvas.restore();
canvas.save();
canvas.translate(34, 30);
canvas.rotate(-wa);
canvas.translate(-34, -30);
canvas.drawLine(const Offset(34, 28), const Offset(44, 27), wp);
canvas.drawLine(const Offset(34, 30), const Offset(45, 30), wp);
canvas.drawLine(const Offset(34, 32), const Offset(44, 33), wp);
canvas.restore();
}
// ============================================================
// 狗狗造型
// ============================================================
void _paintDogHead(Canvas canvas) {
_paintHeadBase(canvas, [
const Color(0xFFE8C090),
const Color(0xFFD4A870),
const Color(0xFFC09050),
]);
_paintDogEars(canvas);
_paintEyes(canvas, 18, 26, 30, 26);
_paintWorriedEyebrows(canvas);
_paintNose(
canvas,
24,
29.5,
5,
3.6,
const Color(0xFF5A4030),
const Color(0xFF3A2515),
0.3,
radius: 4,
);
_paintNoseHighlight(canvas, 22.5, 28.2, 2, 1.0, 0.2);
_paintMouth(canvas, 24, 32);
_paintDogTongue(canvas);
_paintCheeks(canvas, 13, 30, 35, 30);
}
void _paintDogEars(Canvas canvas) {
final droop = earProgress * 4.0 * animationIntensity;
final earPaint = Paint()
..shader = ui.Gradient.linear(const Offset(1, 16), const Offset(12, 36), [
const Color(0xFFD4A870),
const Color(0xFFB08040),
])
..isAntiAlias = true;
final innerPaint = Paint()
..color = const Color(0xFFFFB0B0).withValues(alpha: 0.4)
..isAntiAlias = true;
final le = Path()
..moveTo(9, 16)
..cubicTo(2, 18, 1, 30 + droop, 6, 34 + droop)
..cubicTo(9, 36 + droop, 12, 28, 13, 18)
..close();
canvas.drawPath(le, earPaint);
final li = Path()
..moveTo(10, 18)
..cubicTo(5, 20, 4, 28 + droop, 7, 32 + droop)
..cubicTo(9, 33 + droop, 11, 27, 12, 19)
..close();
canvas.drawPath(li, innerPaint);
final re = Path()
..moveTo(39, 16)
..cubicTo(46, 18, 47, 30 + droop, 42, 34 + droop)
..cubicTo(39, 36 + droop, 36, 28, 35, 18)
..close();
canvas.drawPath(re, earPaint);
final ri = Path()
..moveTo(38, 18)
..cubicTo(43, 20, 44, 28 + droop, 41, 32 + droop)
..cubicTo(39, 33 + droop, 37, 27, 36, 19)
..close();
canvas.drawPath(ri, innerPaint);
}
void _paintDogTongue(Canvas canvas) {
final show =
expression == CharacterExpression.smile ||
expression == CharacterExpression.love ||
expression == CharacterExpression.tickle;
if (!show && expressionProgress < 0.5) return;
final ext = show ? expressionProgress : (1.0 - expressionProgress);
if (ext <= 0.0) return;
canvas.drawOval(
Rect.fromLTWH(22, 33, 4, 3.0 * ext),
Paint()
..color = const Color(0xFFFF8888).withValues(alpha: 0.8 * ext)
..isAntiAlias = true,
);
}
// ============================================================
// 男孩造型
// ============================================================
void _paintBoyHead(Canvas canvas) {
_paintHeadBase(canvas, [
const Color(0xFFFFDAB9),
const Color(0xFFFFCFA8),
const Color(0xFFF0B890),
]);
_paintBoyEars(canvas);
_paintBoyHair(canvas);
_paintEyes(canvas, 18, 26, 30, 26);
_paintBoyEyebrows(canvas);
_paintHumanNose(canvas);
_paintMouth(canvas, 24, 32);
_paintCheeks(canvas, 13, 30, 35, 30);
}
void _paintBoyHair(Canvas canvas) {
final basePaint = Paint()
..shader = ui.Gradient.linear(const Offset(10, 7), const Offset(38, 18), [
const Color(0xFF6B4F3A),
const Color(0xFF4A3728),
])
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(8, 22)
..quadraticBezierTo(10, 8, 24, 7)
..quadraticBezierTo(38, 8, 40, 22)
..quadraticBezierTo(38, 18, 24, 16)
..quadraticBezierTo(10, 18, 8, 22)
..close(),
basePaint,
);
final bangPaint = Paint()
..shader = ui.Gradient.linear(const Offset(9, 12), const Offset(38, 18), [
const Color(0xFF5C4033),
const Color(0xFF4A3728),
])
..strokeWidth = 2.8
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(9, 18)
..quadraticBezierTo(12, 12, 16, 17),
bangPaint,
);
canvas.drawPath(
Path()
..moveTo(15, 16)
..quadraticBezierTo(20, 10, 24, 15),
bangPaint,
);
canvas.drawPath(
Path()
..moveTo(22, 15)
..quadraticBezierTo(28, 10, 33, 17),
bangPaint,
);
canvas.drawPath(
Path()
..moveTo(30, 17)
..quadraticBezierTo(35, 13, 38, 20),
bangPaint,
);
canvas.drawPath(
Path()
..moveTo(12, 12)
..quadraticBezierTo(20, 8, 36, 14),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.08)
..strokeWidth = 2.0
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true,
);
}
void _paintBoyEyebrows(Canvas canvas) {
double lift = 0.0;
if (expression == CharacterExpression.surprise) {
lift = -3.0 * expressionProgress * animationIntensity;
} else if (expression == CharacterExpression.worried) {
lift = 0.0;
}
final isWorriedBrow =
expression == CharacterExpression.worried && expressionProgress > 0.1;
final bp = Paint()
..color = const Color(0xFF5C4033).withValues(alpha: 0.6)
..strokeWidth = 1.8
..strokeCap = ui.StrokeCap.round
..isAntiAlias = true;
if (isWorriedBrow) {
canvas.drawLine(Offset(14, 22 + lift), Offset(20, 24 + lift), bp);
canvas.drawLine(Offset(27, 24 + lift), Offset(34, 22 + lift), bp);
} else {
canvas.drawLine(Offset(14, 22 + lift), Offset(21, 22.5 + lift), bp);
canvas.drawLine(Offset(27, 22.5 + lift), Offset(34, 22 + lift), bp);
}
}
void _paintBoyEars(Canvas canvas) {
final earPaint = Paint()
..shader = ui.Gradient.radial(
const Offset(9, 24),
4,
[const Color(0xFFFFDAB9), const Color(0xFFF0B890)],
[0.0, 1.0],
)
..isAntiAlias = true;
final innerPaint = Paint()
..color = const Color(0xFFFFB0B0).withValues(alpha: 0.3)
..isAntiAlias = true;
canvas.drawArc(
const Rect.fromLTWH(5, 20, 8, 8),
-math.pi / 2,
math.pi,
false,
earPaint,
);
canvas.drawArc(
const Rect.fromLTWH(6, 21, 6, 6),
-math.pi / 2,
math.pi,
false,
innerPaint,
);
canvas.drawArc(
const Rect.fromLTWH(35, 20, 8, 8),
math.pi / 2,
math.pi,
false,
earPaint,
);
canvas.drawArc(
const Rect.fromLTWH(36, 21, 6, 6),
math.pi / 2,
math.pi,
false,
innerPaint,
);
}
void _paintHumanNose(Canvas canvas) {
final sx = 1.0 + noseProgress * 0.2 * animationIntensity;
final sy = 1.0 - noseProgress * 0.2 * animationIntensity;
canvas.save();
canvas.translate(24, 29);
canvas.scale(sx, sy);
canvas.translate(-24, -29);
canvas.drawOval(
const Rect.fromLTWH(22.5, 27.5, 3, 2.5),
Paint()
..color = const Color(0xFFE8B898).withValues(alpha: 0.5)
..isAntiAlias = true,
);
canvas.restore();
}
// ============================================================
// 女孩造型
// ============================================================
void _paintGirlHead(Canvas canvas) {
_paintHeadBase(canvas, [
const Color(0xFFFFE4D0),
const Color(0xFFFFD4B8),
const Color(0xFFF5C0A0),
]);
_paintGirlEars(canvas);
_paintGirlHair(canvas);
_paintEyes(canvas, 18, 26, 30, 26);
_paintWorriedEyebrows(canvas);
_paintGirlLashes(canvas);
_paintHumanNose(canvas);
_paintMouth(canvas, 24, 32);
_paintCheeks(canvas, 13, 30, 35, 30, baseRx: 4.0, maxAlpha: 0.85);
}
void _paintGirlHair(Canvas canvas) {
final basePaint = Paint()
..shader = ui.Gradient.linear(const Offset(6, 5), const Offset(42, 40), [
const Color(0xFF4A3525),
const Color(0xFF2A1505),
])
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(6, 24)
..quadraticBezierTo(6, 6, 24, 5)
..quadraticBezierTo(42, 6, 42, 24)
..quadraticBezierTo(42, 38, 38, 40)
..quadraticBezierTo(36, 36, 34, 32)
..lineTo(34, 22)
..quadraticBezierTo(32, 14, 24, 12)
..quadraticBezierTo(16, 14, 14, 22)
..lineTo(14, 32)
..quadraticBezierTo(12, 36, 10, 40)
..quadraticBezierTo(6, 38, 6, 24)
..close(),
basePaint,
);
final bangPaint = Paint()
..shader = ui.Gradient.linear(
const Offset(12, 11),
const Offset(36, 20),
[const Color(0xFF3A2515), const Color(0xFF2A1505)],
)
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(12, 20)
..quadraticBezierTo(14, 12, 24, 11)
..quadraticBezierTo(34, 12, 36, 20)
..lineTo(35, 19)
..quadraticBezierTo(33, 14, 24, 13)
..quadraticBezierTo(15, 14, 13, 19)
..close(),
bangPaint,
);
canvas.drawPath(
Path()
..moveTo(14, 12)
..quadraticBezierTo(22, 8, 34, 14),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.1)
..strokeWidth = 1.5
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true,
);
final strandPaint = Paint()
..color = const Color(0xFF1A0A00).withValues(alpha: 0.15)
..strokeWidth = 0.5
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(18, 8)
..quadraticBezierTo(17, 20, 12, 36),
strandPaint,
);
canvas.drawPath(
Path()
..moveTo(24, 6)
..quadraticBezierTo(24, 18, 24, 34),
strandPaint,
);
canvas.drawPath(
Path()
..moveTo(30, 8)
..quadraticBezierTo(31, 20, 36, 36),
strandPaint,
);
}
void _paintGirlLashes(Canvas canvas) {
final lp = Paint()
..color = const Color(0xFF3A2515).withValues(alpha: 0.5)
..strokeWidth = 1.0
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true;
canvas.drawPath(
Path()
..moveTo(16, 23)
..quadraticBezierTo(18, 21, 20, 23),
lp,
);
canvas.drawPath(
Path()
..moveTo(15, 22)
..quadraticBezierTo(18, 19, 21, 22),
lp,
);
canvas.drawPath(
Path()
..moveTo(28, 23)
..quadraticBezierTo(30, 21, 32, 23),
lp,
);
canvas.drawPath(
Path()
..moveTo(27, 22)
..quadraticBezierTo(30, 19, 33, 22),
lp,
);
}
void _paintGirlEars(Canvas canvas) {
final earPaint = Paint()
..shader = ui.Gradient.radial(
const Offset(11, 26),
3,
[const Color(0xFFFFE4D0), const Color(0xFFF5C0A0)],
[0.0, 1.0],
)
..isAntiAlias = true;
final innerPaint = Paint()
..color = const Color(0xFFFFB0B0).withValues(alpha: 0.3)
..isAntiAlias = true;
canvas.drawArc(
const Rect.fromLTWH(7, 22, 6, 6),
-math.pi / 2,
math.pi,
false,
earPaint,
);
canvas.drawArc(
const Rect.fromLTWH(8, 23, 4, 4),
-math.pi / 2,
math.pi,
false,
innerPaint,
);
canvas.drawArc(
const Rect.fromLTWH(35, 22, 6, 6),
math.pi / 2,
math.pi,
false,
earPaint,
);
canvas.drawArc(
const Rect.fromLTWH(36, 23, 4, 4),
math.pi / 2,
math.pi,
false,
innerPaint,
);
}
// 共用部件
void _paintNose(
Canvas canvas,
double cx,
double cy,
double w,
double h,
Color c1,
Color c2,
double scaleAmt, {
double radius = 3,
}) {
final sx = 1.0 + noseProgress * scaleAmt * animationIntensity;
final sy = 1.0 - noseProgress * scaleAmt * animationIntensity;
canvas.save();
canvas.translate(cx, cy);
canvas.scale(sx, sy);
canvas.translate(-cx, -cy);
canvas.drawOval(
Rect.fromLTWH(cx - w / 2, cy - h / 2, w, h),
Paint()
..shader = ui.Gradient.radial(
Offset(cx - 1, cy - 0.5),
radius,
[c1, c2],
[0.0, 1.0],
)
..isAntiAlias = true,
);
canvas.restore();
}
void _paintNoseHighlight(
Canvas canvas,
double x,
double y,
double w,
double h,
double alpha,
) {
canvas.drawOval(
Rect.fromLTWH(x, y, w, h),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: alpha)
..isAntiAlias = true,
);
}
void _paintEyes(
Canvas canvas,
double lCx,
double cy,
double rCx,
double cy2,
) {
final bf = _getBlinkFactor();
final isWink =
expression == CharacterExpression.wink && expressionProgress > 0.3;
final isLove =
expression == CharacterExpression.love && expressionProgress > 0.3;
final isLookRight = expression == CharacterExpression.lookRight;
final isDizzy =
expression == CharacterExpression.dizzy && expressionProgress > 0.3;
final isWorried =
expression == CharacterExpression.worried && expressionProgress > 0.3;
final dx = eyeOffset.dx, dy = eyeOffset.dy;
final extraDx = isLookRight ? 2.0 : 0.0;
if (isDizzy) {
_paintDizzyEye(canvas, lCx + dx, cy + dy);
_paintDizzyEye(canvas, rCx + dx, cy2 + dy);
} else if (isLove) {
_paintHeartEye(canvas, lCx + dx, cy + dy);
_paintHeartEye(canvas, rCx + dx, cy2 + dy);
} else if (isWink) {
_paintSingleEye(
canvas,
lCx + dx + extraDx,
cy + dy,
bf,
false,
isWorried ? 0.9 : 1.0,
);
_paintSingleEye(
canvas,
rCx + dx + extraDx,
cy2 + dy,
0.0,
true,
isWorried ? 0.9 : 1.0,
);
} else {
_paintSingleEye(
canvas,
lCx + dx + extraDx,
cy + dy,
bf,
false,
isWorried ? 0.9 : 1.0,
);
_paintSingleEye(
canvas,
rCx + dx + extraDx,
cy2 + dy,
bf,
false,
isWorried ? 0.9 : 1.0,
);
}
}
double _getMouthCurve() {
if (expression != CharacterExpression.idle) return 0.0;
return switch (mood) {
CharacterMood.happy => 0.3,
CharacterMood.excited => 0.5,
CharacterMood.neutral => 0.0,
CharacterMood.bored => -0.1,
CharacterMood.sleepy => -0.05,
CharacterMood.worried => -0.15,
null => 0.0,
};
}
double _getMoodEyeOpenness() {
if (expression != CharacterExpression.idle) return 1.0;
return switch (mood) {
CharacterMood.happy => 1.0,
CharacterMood.excited => 1.2,
CharacterMood.neutral => 1.0,
CharacterMood.bored => 0.7,
CharacterMood.sleepy => 0.4,
CharacterMood.worried => 0.9,
null => 1.0,
};
}
double _getBlinkFactor() {
final idleBlink = idleProgress < 0.1 ? (0.1 - idleProgress) / 0.1 : 0.0;
final exprBlink = expression == CharacterExpression.blink
? expressionProgress
: 0.0;
final thinkBlink = expression == CharacterExpression.think
? 0.3 * expressionProgress
: 0.0;
final moodBlink =
mood == CharacterMood.sleepy && expression == CharacterExpression.idle
? 0.4
: 0.0;
return (idleBlink + exprBlink + thinkBlink + moodBlink).clamp(0.0, 1.0);
}
void _paintSingleEye(
Canvas canvas,
double cx,
double cy,
double bf,
bool closed, [
double eyeScale = 1.0,
]) {
final moodScale = _getMoodEyeOpenness() * eyeScale;
final ry = closed ? 0.5 : (3.3 * (1.0 - bf * 0.85) * moodScale);
if (ry < 0.5) {
canvas.drawLine(
Offset(cx - 2.5, cy),
Offset(cx + 2.5, cy),
Paint()
..color = const Color(0xFF3A2515)
..strokeWidth = 1.2
..strokeCap = ui.StrokeCap.round
..isAntiAlias = true,
);
return;
}
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy), width: 5.6, height: ry * 2),
Paint()
..color = const Color(0xFFFFFFFF)
..isAntiAlias = true,
);
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy), width: 3.2, height: ry * 1.2),
Paint()
..color = const Color(0xFF3A2515)
..isAntiAlias = true,
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(cx - 0.8, cy - ry * 0.3),
width: 2.2,
height: 2.2,
),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.85)
..isAntiAlias = true,
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(cx + 0.6, cy + ry * 0.2),
width: 1.0,
height: 1.0,
),
Paint()
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.4)
..isAntiAlias = true,
);
}
void _paintDizzyEye(Canvas canvas, double cx, double cy) {
final rotation = expressionProgress * math.pi * 2 * 3;
canvas.save();
canvas.translate(cx, cy);
canvas.rotate(rotation);
canvas.translate(-cx, -cy);
final dp = Paint()
..color = const Color(0xFF3A2515)
..strokeWidth = 1.5
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true;
canvas.drawLine(Offset(cx - 2.5, cy - 2.5), Offset(cx + 2.5, cy + 2.5), dp);
canvas.drawLine(Offset(cx + 2.5, cy - 2.5), Offset(cx - 2.5, cy + 2.5), dp);
canvas.restore();
}
void _paintHeartEye(Canvas canvas, double cx, double cy) {
final sc = 0.8 + expressionProgress * 0.3;
canvas.save();
canvas.translate(cx, cy);
canvas.scale(sc, sc);
canvas.translate(-cx, -cy);
canvas.drawPath(
Path()
..moveTo(cx, cy + 2)
..cubicTo(cx - 3, cy - 1, cx - 3, cy - 3, cx, cy - 1.5)
..cubicTo(cx + 3, cy - 3, cx + 3, cy - 1, cx, cy + 2)
..close(),
Paint()
..color = const Color(0xFFFF6B8A)
..isAntiAlias = true,
);
canvas.restore();
}
void _paintMouth(Canvas canvas, double cx, double cy) {
final mp = Paint()
..color = const Color(0xFF8B5E3C)
..strokeWidth = 1.0
..style = ui.PaintingStyle.stroke
..strokeCap = ui.StrokeCap.round
..isAntiAlias = true;
final fp = Paint()
..color = const Color(0xFF8B5E3C).withValues(alpha: 0.3)
..isAntiAlias = true;
switch (expression) {
case CharacterExpression.idle:
final curve = _getMouthCurve();
if (curve.abs() < 0.01) {
canvas.drawLine(
Offset(cx - 2, cy),
Offset(cx + 2, cy),
mp..strokeWidth = 0.8,
);
} else if (curve > 0) {
canvas.drawPath(
Path()
..moveTo(cx - 2, cy)
..quadraticBezierTo(cx, cy + curve * 8, cx + 2, cy),
mp..strokeWidth = 0.8,
);
} else {
canvas.drawPath(
Path()
..moveTo(cx - 2, cy)
..quadraticBezierTo(cx, cy + curve * 8, cx + 2, cy),
mp..strokeWidth = 0.8,
);
}
case CharacterExpression.blink:
canvas.drawPath(
Path()
..moveTo(cx - 2, cy)
..quadraticBezierTo(cx, cy + 1.5, cx + 2, cy),
mp,
);
case CharacterExpression.smile:
case CharacterExpression.love:
final p = Path()
..moveTo(cx - 6, cy - 1)
..quadraticBezierTo(cx, cy + 6, cx + 6, cy - 1);
canvas.drawPath(p, mp);
canvas.drawPath(
Path()
..moveTo(cx - 6, cy - 1)
..quadraticBezierTo(cx, cy + 6, cx + 6, cy - 1)
..close(),
fp,
);
case CharacterExpression.surprise:
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy + 1), width: 4, height: 5),
mp..style = ui.PaintingStyle.stroke,
);
case CharacterExpression.wink:
canvas.drawPath(
Path()
..moveTo(cx - 3, cy)
..quadraticBezierTo(cx, cy + 3, cx + 3, cy),
mp,
);
case CharacterExpression.pout:
canvas.drawPath(
Path()
..moveTo(cx - 3, cy + 1)
..quadraticBezierTo(cx, cy - 1.5, cx + 3, cy + 1),
mp,
);
case CharacterExpression.tickle:
canvas.drawPath(
Path()
..moveTo(cx - 5, cy)
..quadraticBezierTo(cx, cy + 5, cx + 5, cy),
mp,
);
case CharacterExpression.lookRight:
canvas.drawPath(
Path()
..moveTo(cx - 2, cy)
..quadraticBezierTo(cx + 1, cy + 1.5, cx + 3, cy),
mp,
);
case CharacterExpression.think:
canvas.drawPath(
Path()
..moveTo(cx - 3, cy + 1)
..quadraticBezierTo(cx, cy - 1.5, cx + 3, cy - 1),
mp,
);
case CharacterExpression.dizzy:
final wave = expressionProgress * 2.0;
canvas.drawPath(
Path()
..moveTo(cx - 4, cy)
..quadraticBezierTo(cx - 2, cy - 2 + wave, cx, cy)
..quadraticBezierTo(cx + 2, cy + 2 - wave, cx + 4, cy),
mp..strokeWidth = 1.2,
);
case CharacterExpression.worried:
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy + 1), width: 4, height: 3.5),
Paint()
..color = const Color(
0xFF8B5E3C,
).withValues(alpha: 0.4 * expressionProgress)
..isAntiAlias = true,
);
canvas.drawOval(
Rect.fromCenter(center: Offset(cx, cy + 1), width: 4, height: 3.5),
mp..style = ui.PaintingStyle.stroke,
);
case CharacterExpression.speaking:
final openAmt = 2.0 + expressionProgress * 4.0;
canvas.drawOval(
Rect.fromCenter(
center: Offset(cx, cy + 1),
width: 5,
height: openAmt,
),
fp,
);
canvas.drawOval(
Rect.fromCenter(
center: Offset(cx, cy + 1),
width: 5,
height: openAmt,
),
mp,
);
}
}
void _paintCheeks(
Canvas canvas,
double lCx,
double cy,
double rCx,
double cy2, {
double baseRx = 3.5,
double maxAlpha = 0.7,
}) {
final rx = baseRx + cheekProgress * 2.0 * animationIntensity;
final ry = 2.2 + cheekProgress * 1.0 * animationIntensity;
final alpha = cheekProgress * maxAlpha * animationIntensity;
if (alpha < 0.01) return;
for (final c in [Offset(lCx, cy), Offset(rCx, cy2)]) {
canvas.drawOval(
Rect.fromCenter(center: c, width: rx * 2, height: ry * 2),
Paint()
..shader = ui.Gradient.radial(
c,
rx,
[
const Color(0xFFFFB0B0).withValues(alpha: alpha),
const Color(0xFFFFB0B0).withValues(alpha: 0.0),
],
[0.0, 1.0],
)
..isAntiAlias = true,
);
}
}
void _paintWorriedEyebrows(Canvas canvas) {
if (expression != CharacterExpression.worried || expressionProgress < 0.1)
return;
const s = 1.0;
final paint = Paint()
..color = const Color(
0xFF5C4033,
).withValues(alpha: 0.6 * expressionProgress)
..strokeWidth = 1.5 * s
..strokeCap = ui.StrokeCap.round
..style = ui.PaintingStyle.stroke
..isAntiAlias = true;
canvas.drawLine(const Offset(14, 22), const Offset(20, 24), paint);
canvas.drawLine(const Offset(28, 24), const Offset(34, 22), paint);
}
@override
bool shouldRepaint(_AppBarCharacterPainter old) =>
characterId != old.characterId ||
expression != old.expression ||
expressionProgress != old.expressionProgress ||
earProgress != old.earProgress ||
noseProgress != old.noseProgress ||
cheekProgress != old.cheekProgress ||
bounceScale != old.bounceScale ||
bounceRotation != old.bounceRotation ||
eyeOffset != old.eyeOffset ||
animationIntensity != old.animationIntensity ||
idleProgress != old.idleProgress ||
mood != old.mood;
}