1446 lines
42 KiB
Dart
1446 lines
42 KiB
Dart
// ============================================================
|
||
// 闲言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;
|
||
}
|