1401 lines
40 KiB
Dart
1401 lines
40 KiB
Dart
// ============================================================
|
||
// 闲言APP — Tab图标精灵动画组件
|
||
// 创建时间: 2026-05-14
|
||
// 更新时间: 2026-05-22
|
||
// 作用: 底部导航Tab图标,支持表情精灵/呼吸光效/果冻弹跳/相邻注视/文字显隐
|
||
// 上次更新: 集成PerformanceOrchestrator前后台感知,后台暂停glow呼吸动画
|
||
// ============================================================
|
||
|
||
import 'dart:math' as math;
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/services/performance/performance_orchestrator.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../core/theme/app_typography.dart';
|
||
|
||
enum TabSpriteType { home, discover, profile }
|
||
|
||
class TabIconSprite extends StatefulWidget {
|
||
const TabIconSprite({
|
||
super.key,
|
||
required this.type,
|
||
required this.label,
|
||
required this.isSelected,
|
||
required this.adjacentDirection,
|
||
required this.animationIntensity,
|
||
required this.characterId,
|
||
required this.eyeScale,
|
||
required this.mouthCurve,
|
||
required this.bounceMultiplier,
|
||
});
|
||
|
||
final TabSpriteType type;
|
||
final String label;
|
||
final bool isSelected;
|
||
final int adjacentDirection;
|
||
final double animationIntensity;
|
||
final String characterId;
|
||
final double eyeScale;
|
||
final double mouthCurve;
|
||
final double bounceMultiplier;
|
||
|
||
@override
|
||
State<TabIconSprite> createState() => _TabIconSpriteState();
|
||
}
|
||
|
||
class _TabIconSpriteState extends State<TabIconSprite>
|
||
with TickerProviderStateMixin {
|
||
late AnimationController _bounceController;
|
||
late AnimationController _glowController;
|
||
late AnimationController _expressionController;
|
||
late AnimationController _lookController;
|
||
late AnimationController _labelController;
|
||
|
||
late Animation<double> _bounceScale;
|
||
late Animation<double> _bounceRotation;
|
||
late Animation<double> _glowPulse;
|
||
late Animation<double> _expressionProgress;
|
||
late Animation<double> _lookOffset;
|
||
late Animation<double> _labelOpacity;
|
||
late Animation<Offset> _labelOffset;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_bounceController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 500),
|
||
);
|
||
_glowController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 2000),
|
||
);
|
||
_expressionController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 300),
|
||
);
|
||
_lookController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 250),
|
||
);
|
||
_labelController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 200),
|
||
);
|
||
|
||
_setupBounceAnimations();
|
||
|
||
_glowPulse = Tween(begin: 0.3, end: 1.0).animate(
|
||
CurvedAnimation(parent: _glowController, curve: Curves.easeInOut),
|
||
);
|
||
|
||
_expressionProgress = CurvedAnimation(
|
||
parent: _expressionController,
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
|
||
_lookOffset = Tween(begin: 0.0, end: 1.0).animate(
|
||
CurvedAnimation(parent: _lookController, curve: Curves.easeOutCubic),
|
||
);
|
||
|
||
_labelOpacity = CurvedAnimation(
|
||
parent: _labelController,
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
|
||
_labelOffset = Tween<Offset>(begin: Offset.zero, end: const Offset(0, 0.5))
|
||
.animate(
|
||
CurvedAnimation(parent: _labelController, curve: Curves.easeInCubic),
|
||
);
|
||
|
||
if (widget.isSelected) {
|
||
_expressionController.value = 1.0;
|
||
_glowController.repeat(reverse: true);
|
||
_labelController.value = 1.0;
|
||
}
|
||
|
||
PerformanceOrchestrator.instance.onForeground(_onForeground);
|
||
PerformanceOrchestrator.instance.onBackground(_onBackground);
|
||
}
|
||
|
||
void _setupBounceAnimations() {
|
||
_bounceScale = TweenSequence<double>([
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 1.0,
|
||
end: 1.0 + 0.35 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 30,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 1.0 + 0.35 * widget.bounceMultiplier,
|
||
end: 1.0 - 0.08 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeInCubic)),
|
||
weight: 25,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 1.0 - 0.08 * widget.bounceMultiplier,
|
||
end: 1.0 + 0.05 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 20,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 1.0 + 0.05 * widget.bounceMultiplier,
|
||
end: 1.0,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 25,
|
||
),
|
||
]).animate(_bounceController);
|
||
|
||
_bounceRotation = TweenSequence<double>([
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 0.0,
|
||
end: 0.08 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 25,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 0.08 * widget.bounceMultiplier,
|
||
end: -0.05 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeInOutCubic)),
|
||
weight: 25,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: -0.05 * widget.bounceMultiplier,
|
||
end: 0.02 * widget.bounceMultiplier,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 25,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(
|
||
begin: 0.02 * widget.bounceMultiplier,
|
||
end: 0.0,
|
||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||
weight: 25,
|
||
),
|
||
]).animate(_bounceController);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(TabIconSprite oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
final intensity = widget.animationIntensity;
|
||
|
||
if (widget.bounceMultiplier != oldWidget.bounceMultiplier) {
|
||
_setupBounceAnimations();
|
||
}
|
||
|
||
if (widget.isSelected && !oldWidget.isSelected) {
|
||
_bounceController.duration = Duration(
|
||
milliseconds: (500 * intensity).round(),
|
||
);
|
||
_bounceController.forward(from: 0.0);
|
||
_expressionController.duration = Duration(
|
||
milliseconds: (300 * intensity).round(),
|
||
);
|
||
_expressionController.forward();
|
||
_glowController.repeat(reverse: true);
|
||
_lookController.reverse();
|
||
_labelController.forward();
|
||
} else if (!widget.isSelected && oldWidget.isSelected) {
|
||
_expressionController.reverse();
|
||
_glowController.stop();
|
||
_glowController.value = 0.0;
|
||
_bounceController.reverse();
|
||
_labelController.reverse();
|
||
}
|
||
|
||
if (!widget.isSelected && widget.adjacentDirection != 0) {
|
||
_lookController.duration = Duration(
|
||
milliseconds: (250 * intensity).round(),
|
||
);
|
||
_lookController.forward();
|
||
} else if (!widget.isSelected && widget.adjacentDirection == 0) {
|
||
_lookController.reverse();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
PerformanceOrchestrator.instance.removeCallbacks(_onForeground);
|
||
PerformanceOrchestrator.instance.removeCallbacks(_onBackground);
|
||
_bounceController.dispose();
|
||
_glowController.dispose();
|
||
_expressionController.dispose();
|
||
_lookController.dispose();
|
||
_labelController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onForeground() {
|
||
if (widget.isSelected) {
|
||
_glowController.repeat(reverse: true);
|
||
}
|
||
}
|
||
|
||
void _onBackground() {
|
||
_glowController.stop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
|
||
return AnimatedBuilder(
|
||
animation: Listenable.merge([
|
||
_bounceController,
|
||
_glowController,
|
||
_expressionController,
|
||
_lookController,
|
||
_labelController,
|
||
]),
|
||
builder: (context, _) {
|
||
final scale = _bounceScale.value;
|
||
final rotation = _bounceRotation.value;
|
||
final glowAlpha = _glowPulse.value;
|
||
final exprProgress = _expressionProgress.value;
|
||
final lookDir = widget.adjacentDirection.toDouble() * _lookOffset.value;
|
||
final labelAlpha = 1.0 - _labelOpacity.value;
|
||
final labelSlide = _labelOffset.value;
|
||
|
||
final iconSize = widget.isSelected ? 36.0 : 24.0;
|
||
final glowSize = 40.0 * widget.bounceMultiplier;
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(
|
||
width: glowSize,
|
||
height: glowSize,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
if (widget.isSelected)
|
||
CustomPaint(
|
||
size: Size(glowSize * 1.4, glowSize * 1.4),
|
||
painter: _GlowPainter(
|
||
color: ext.accent,
|
||
alpha: glowAlpha * 0.55,
|
||
radiusMultiplier: 1.4,
|
||
),
|
||
),
|
||
Transform.scale(
|
||
scale: scale,
|
||
child: Transform.rotate(
|
||
angle: rotation,
|
||
child: SizedBox(
|
||
width: iconSize,
|
||
height: iconSize,
|
||
child: CustomPaint(
|
||
painter: _RefinedSpritePainter(
|
||
type: widget.type,
|
||
characterId: widget.characterId,
|
||
expressionProgress: exprProgress,
|
||
lookOffset: lookDir,
|
||
eyeScale: widget.eyeScale,
|
||
mouthCurve: widget.mouthCurve,
|
||
isSelected: widget.isSelected,
|
||
color: widget.isSelected
|
||
? (ext.isDark ? Colors.white : ext.accent)
|
||
: (ext.isDark
|
||
? Colors.white38
|
||
: const Color(0xFFAEAEB2)),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (widget.isSelected)
|
||
...List.generate(
|
||
7,
|
||
(i) => _FloatingParticle(
|
||
color: ext.accent,
|
||
index: i,
|
||
progress: glowAlpha,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(
|
||
height: 16,
|
||
child: FractionalTranslation(
|
||
translation: labelSlide,
|
||
child: Opacity(
|
||
opacity: labelAlpha.clamp(0.0, 1.0),
|
||
child: Text(
|
||
widget.label,
|
||
style: AppTypography.caption1.copyWith(
|
||
color: widget.isSelected
|
||
? (ext.isDark ? Colors.white : ext.accent)
|
||
: (ext.isDark
|
||
? Colors.white38
|
||
: const Color(0xFFAEAEB2)),
|
||
fontWeight: FontWeight.w500,
|
||
fontSize: 12,
|
||
height: 1.2,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _GlowPainter extends CustomPainter {
|
||
_GlowPainter({
|
||
required this.color,
|
||
required this.alpha,
|
||
this.radiusMultiplier = 1.0,
|
||
});
|
||
|
||
final Color color;
|
||
final double alpha;
|
||
final double radiusMultiplier;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final center = Offset(size.width / 2, size.height / 2);
|
||
final radius = (size.width / 2) * radiusMultiplier;
|
||
|
||
final paint = Paint()
|
||
..shader = ui.Gradient.radial(
|
||
center,
|
||
radius,
|
||
[
|
||
color.withValues(alpha: alpha.clamp(0.0, 1.0)),
|
||
color.withValues(alpha: alpha.clamp(0.0, 1.0) * 0.6),
|
||
color.withValues(alpha: alpha.clamp(0.0, 1.0) * 0.2),
|
||
color.withValues(alpha: 0.0),
|
||
],
|
||
[0.0, 0.35, 0.7, 1.0],
|
||
)
|
||
..isAntiAlias = true
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
|
||
|
||
canvas.drawCircle(center, radius, paint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_GlowPainter oldDelegate) {
|
||
return alpha != oldDelegate.alpha ||
|
||
color != oldDelegate.color ||
|
||
radiusMultiplier != oldDelegate.radiusMultiplier;
|
||
}
|
||
}
|
||
|
||
class _FloatingParticle extends StatelessWidget {
|
||
const _FloatingParticle({
|
||
required this.color,
|
||
required this.index,
|
||
required this.progress,
|
||
});
|
||
|
||
final Color color;
|
||
final int index;
|
||
final double progress;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final angle = (index * 0.898) + progress * 1.047;
|
||
final radius = 22.0 + progress * 14.0;
|
||
final dx = radius * math.cos(angle);
|
||
final dy = radius * math.sin(angle);
|
||
final particleAlpha = (1.0 - progress).clamp(0.0, 1.0) * 0.7;
|
||
final particleSize = 4.5 - index * 0.3;
|
||
|
||
return Transform.translate(
|
||
offset: Offset(dx, dy),
|
||
child: Container(
|
||
width: particleSize,
|
||
height: particleSize,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: color.withValues(alpha: particleAlpha),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: color.withValues(alpha: particleAlpha * 0.6),
|
||
blurRadius: 6,
|
||
spreadRadius: 2,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _RefinedSpritePainter extends CustomPainter {
|
||
_RefinedSpritePainter({
|
||
required this.type,
|
||
required this.characterId,
|
||
required this.expressionProgress,
|
||
required this.lookOffset,
|
||
required this.eyeScale,
|
||
required this.mouthCurve,
|
||
required this.isSelected,
|
||
required this.color,
|
||
});
|
||
|
||
final TabSpriteType type;
|
||
final String characterId;
|
||
final double expressionProgress;
|
||
final double lookOffset;
|
||
final double eyeScale;
|
||
final double mouthCurve;
|
||
final bool isSelected;
|
||
final Color color;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final s = size.width / 32.0;
|
||
canvas.save();
|
||
canvas.scale(s, s);
|
||
|
||
switch (characterId) {
|
||
case 'cat':
|
||
_paintCat(canvas);
|
||
case 'dog':
|
||
_paintDog(canvas);
|
||
case 'boy':
|
||
_paintBoy(canvas);
|
||
case 'girl':
|
||
_paintGirl(canvas);
|
||
default:
|
||
_paintCat(canvas);
|
||
}
|
||
|
||
canvas.restore();
|
||
}
|
||
|
||
// ============================================================
|
||
// 猫咪造型
|
||
// ============================================================
|
||
void _paintCat(Canvas canvas) {
|
||
final fill = _fill();
|
||
final subFill = _subFill();
|
||
final stroke = _stroke(1.2);
|
||
|
||
switch (type) {
|
||
case TabSpriteType.home:
|
||
_paintCatHome(canvas, fill, subFill, stroke);
|
||
case TabSpriteType.discover:
|
||
_paintCatDiscover(canvas, fill, subFill, stroke);
|
||
case TabSpriteType.profile:
|
||
_paintCatProfile(canvas, fill, subFill, stroke);
|
||
}
|
||
}
|
||
|
||
void _paintCatHome(Canvas canvas, Paint fill, Paint subFill, Paint stroke) {
|
||
final roof = Path();
|
||
roof.moveTo(16, 2);
|
||
roof.cubicTo(14.5, 4, 3, 10, 3, 12);
|
||
roof.lineTo(29, 12);
|
||
roof.cubicTo(29, 10, 17.5, 4, 16, 2);
|
||
roof.close();
|
||
canvas.drawPath(roof, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(5, 12, 22, 14),
|
||
topLeft: const Radius.circular(2),
|
||
topRight: const Radius.circular(2),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
final door = Path();
|
||
door.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(13, 19, 6, 7),
|
||
topLeft: const Radius.circular(1),
|
||
topRight: const Radius.circular(1),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(door, subFill);
|
||
|
||
final doorknob = _fill(alpha: 0.5);
|
||
canvas.drawCircle(const Offset(17.5, 23), 0.6, doorknob);
|
||
|
||
final window = _fill(alpha: 0.25);
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(7, 14, 4, 3),
|
||
topLeft: const Radius.circular(0.8),
|
||
topRight: const Radius.circular(0.8),
|
||
bottomLeft: const Radius.circular(0.8),
|
||
bottomRight: const Radius.circular(0.8),
|
||
),
|
||
window,
|
||
);
|
||
canvas.drawRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(21, 14, 4, 3),
|
||
topLeft: const Radius.circular(0.8),
|
||
topRight: const Radius.circular(0.8),
|
||
bottomLeft: const Radius.circular(0.8),
|
||
bottomRight: const Radius.circular(0.8),
|
||
),
|
||
window,
|
||
);
|
||
|
||
_paintCatEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 14);
|
||
|
||
if (isSelected) {
|
||
_paintCatWhiskers(canvas, stroke);
|
||
_paintChimneyHeart(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintCatDiscover(
|
||
Canvas canvas,
|
||
Paint fill,
|
||
Paint subFill,
|
||
Paint stroke,
|
||
) {
|
||
canvas.drawCircle(const Offset(16, 16), 11, fill);
|
||
|
||
final innerRing = _stroke(1.2, alpha: 0.25);
|
||
canvas.drawCircle(const Offset(16, 16), 7.5, innerRing);
|
||
|
||
final needle = Path();
|
||
needle.moveTo(16, 7);
|
||
needle.cubicTo(15.2, 10, 14.8, 13, 16, 16);
|
||
needle.cubicTo(17.2, 13, 16.8, 10, 16, 7);
|
||
needle.close();
|
||
canvas.drawPath(needle, fill);
|
||
|
||
final needle2 = Path();
|
||
needle2.moveTo(16, 25);
|
||
needle2.cubicTo(15.2, 22, 14.8, 19, 16, 16);
|
||
needle2.cubicTo(17.2, 19, 16.8, 22, 16, 25);
|
||
needle2.close();
|
||
canvas.drawPath(needle2, _fill(alpha: 0.35));
|
||
|
||
canvas.drawCircle(const Offset(16, 16), 1.5, _fill(alpha: 0.5));
|
||
|
||
_paintCatEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 16);
|
||
|
||
if (isSelected) {
|
||
_paintCatWhiskers(canvas, stroke);
|
||
_paintCompassSparks(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintCatProfile(
|
||
Canvas canvas,
|
||
Paint fill,
|
||
Paint subFill,
|
||
Paint stroke,
|
||
) {
|
||
final head = Path();
|
||
head.addOval(const Rect.fromLTWH(8, 4, 16, 14));
|
||
canvas.drawPath(head, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(7, 17, 18, 10),
|
||
topLeft: const Radius.circular(6),
|
||
topRight: const Radius.circular(6),
|
||
bottomLeft: const Radius.circular(4),
|
||
bottomRight: const Radius.circular(4),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
_paintCatEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 10);
|
||
|
||
if (isSelected) {
|
||
_paintCatWhiskers(canvas, stroke);
|
||
_paintHalo(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintCatEars(Canvas canvas, Paint fill, Paint subFill) {
|
||
final leftEar = Path();
|
||
leftEar.moveTo(7, 9);
|
||
leftEar.cubicTo(6, 5, 8, 1, 11, 3);
|
||
leftEar.cubicTo(10, 5, 9, 7, 10, 9);
|
||
leftEar.close();
|
||
canvas.drawPath(leftEar, fill);
|
||
|
||
final leftInner = Path();
|
||
leftInner.moveTo(8, 8);
|
||
leftInner.cubicTo(7.5, 5.5, 9, 3, 10.5, 4);
|
||
leftInner.cubicTo(10, 5.5, 9.5, 7, 10, 8);
|
||
leftInner.close();
|
||
canvas.drawPath(leftInner, subFill);
|
||
|
||
final rightEar = Path();
|
||
rightEar.moveTo(22, 9);
|
||
rightEar.cubicTo(22, 7, 21, 5, 21, 3);
|
||
rightEar.cubicTo(24, 1, 26, 5, 25, 9);
|
||
rightEar.close();
|
||
canvas.drawPath(rightEar, fill);
|
||
|
||
final rightInner = Path();
|
||
rightInner.moveTo(22, 8);
|
||
rightInner.cubicTo(22, 6, 21.5, 5.5, 21.5, 4);
|
||
rightInner.cubicTo(23, 3, 24.5, 5.5, 24, 8);
|
||
rightInner.close();
|
||
canvas.drawPath(rightInner, subFill);
|
||
}
|
||
|
||
void _paintCatWhiskers(Canvas canvas, Paint stroke) {
|
||
final whisker = _stroke(0.8, alpha: 0.5);
|
||
for (var i = 0; i < 3; i++) {
|
||
final y = 14.0 + i * 1.5;
|
||
final bend = (i - 1) * 1.5;
|
||
canvas.drawLine(Offset(9, y), Offset(2, y + bend), whisker);
|
||
canvas.drawLine(Offset(23, y), Offset(30, y + bend), whisker);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 狗狗造型
|
||
// ============================================================
|
||
void _paintDog(Canvas canvas) {
|
||
final fill = _fill();
|
||
final subFill = _subFill();
|
||
final stroke = _stroke(1.2);
|
||
|
||
switch (type) {
|
||
case TabSpriteType.home:
|
||
_paintDogHome(canvas, fill, subFill, stroke);
|
||
case TabSpriteType.discover:
|
||
_paintDogDiscover(canvas, fill, subFill, stroke);
|
||
case TabSpriteType.profile:
|
||
_paintDogProfile(canvas, fill, subFill, stroke);
|
||
}
|
||
}
|
||
|
||
void _paintDogHome(Canvas canvas, Paint fill, Paint subFill, Paint stroke) {
|
||
final roof = Path();
|
||
roof.moveTo(16, 3);
|
||
roof.cubicTo(14.5, 5, 4, 10, 4, 12);
|
||
roof.lineTo(28, 12);
|
||
roof.cubicTo(28, 10, 17.5, 5, 16, 3);
|
||
roof.close();
|
||
canvas.drawPath(roof, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(5, 12, 22, 14),
|
||
topLeft: const Radius.circular(2),
|
||
topRight: const Radius.circular(2),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
final door = Path();
|
||
door.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(13, 19, 6, 7),
|
||
topLeft: const Radius.circular(1),
|
||
topRight: const Radius.circular(1),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(door, subFill);
|
||
|
||
canvas.drawCircle(const Offset(17.5, 23), 0.6, _fill(alpha: 0.5));
|
||
|
||
_paintDogEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 14);
|
||
|
||
if (isSelected) {
|
||
_paintDogTongue(canvas);
|
||
_paintChimneyHeart(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintDogDiscover(
|
||
Canvas canvas,
|
||
Paint fill,
|
||
Paint subFill,
|
||
Paint stroke,
|
||
) {
|
||
canvas.drawCircle(const Offset(16, 16), 11, fill);
|
||
|
||
final innerRing = _stroke(1.2, alpha: 0.25);
|
||
canvas.drawCircle(const Offset(16, 16), 7.5, innerRing);
|
||
|
||
final needle = Path();
|
||
needle.moveTo(16, 7);
|
||
needle.cubicTo(15.2, 10, 14.8, 13, 16, 16);
|
||
needle.cubicTo(17.2, 13, 16.8, 10, 16, 7);
|
||
needle.close();
|
||
canvas.drawPath(needle, fill);
|
||
|
||
final needle2 = Path();
|
||
needle2.moveTo(16, 25);
|
||
needle2.cubicTo(15.2, 22, 14.8, 19, 16, 16);
|
||
needle2.cubicTo(17.2, 19, 16.8, 22, 16, 25);
|
||
needle2.close();
|
||
canvas.drawPath(needle2, _fill(alpha: 0.35));
|
||
|
||
canvas.drawCircle(const Offset(16, 16), 1.5, _fill(alpha: 0.5));
|
||
|
||
_paintDogEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 16);
|
||
|
||
if (isSelected) {
|
||
_paintDogTongue(canvas);
|
||
_paintCompassSparks(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintDogProfile(
|
||
Canvas canvas,
|
||
Paint fill,
|
||
Paint subFill,
|
||
Paint stroke,
|
||
) {
|
||
final head = Path();
|
||
head.addOval(const Rect.fromLTWH(8, 4, 16, 14));
|
||
canvas.drawPath(head, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(7, 17, 18, 10),
|
||
topLeft: const Radius.circular(6),
|
||
topRight: const Radius.circular(6),
|
||
bottomLeft: const Radius.circular(4),
|
||
bottomRight: const Radius.circular(4),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
_paintDogEars(canvas, fill, subFill);
|
||
_paintFace(canvas, 16, 10);
|
||
|
||
if (isSelected) {
|
||
_paintDogTongue(canvas);
|
||
_paintHalo(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintDogEars(Canvas canvas, Paint fill, Paint subFill) {
|
||
final leftEar = Path();
|
||
leftEar.moveTo(8, 8);
|
||
leftEar.cubicTo(5, 8, 3, 12, 4, 17);
|
||
leftEar.cubicTo(4.5, 19, 6, 19, 8, 17);
|
||
leftEar.cubicTo(9, 14, 9, 10, 8, 8);
|
||
leftEar.close();
|
||
canvas.drawPath(leftEar, fill);
|
||
|
||
final leftInner = Path();
|
||
leftInner.moveTo(7.5, 9.5);
|
||
leftInner.cubicTo(5.5, 10, 4.5, 13, 5, 16);
|
||
leftInner.cubicTo(5.5, 17, 6.5, 17, 7.5, 15.5);
|
||
leftInner.cubicTo(8, 13, 8, 11, 7.5, 9.5);
|
||
leftInner.close();
|
||
canvas.drawPath(leftInner, subFill);
|
||
|
||
final rightEar = Path();
|
||
rightEar.moveTo(24, 8);
|
||
rightEar.cubicTo(27, 8, 29, 12, 28, 17);
|
||
rightEar.cubicTo(27.5, 19, 26, 19, 24, 17);
|
||
rightEar.cubicTo(23, 14, 23, 10, 24, 8);
|
||
rightEar.close();
|
||
canvas.drawPath(rightEar, fill);
|
||
|
||
final rightInner = Path();
|
||
rightInner.moveTo(24.5, 9.5);
|
||
rightInner.cubicTo(26.5, 10, 27.5, 13, 27, 16);
|
||
rightInner.cubicTo(26.5, 17, 25.5, 17, 24.5, 15.5);
|
||
rightInner.cubicTo(24, 13, 24, 11, 24.5, 9.5);
|
||
rightInner.close();
|
||
canvas.drawPath(rightInner, subFill);
|
||
}
|
||
|
||
void _paintDogTongue(Canvas canvas) {
|
||
final tongue = Path();
|
||
tongue.moveTo(15, 18);
|
||
tongue.cubicTo(14.5, 20, 14.5, 22, 16, 23);
|
||
tongue.cubicTo(17.5, 22, 17.5, 20, 17, 18);
|
||
tongue.close();
|
||
canvas.drawPath(tongue, _fill(alpha: 0.5));
|
||
|
||
final tongueLine = _stroke(0.5, alpha: 0.3);
|
||
canvas.drawLine(const Offset(16, 18.5), const Offset(16, 22), tongueLine);
|
||
}
|
||
|
||
// ============================================================
|
||
// 男孩造型
|
||
// ============================================================
|
||
void _paintBoy(Canvas canvas) {
|
||
final fill = _fill();
|
||
final subFill = _subFill();
|
||
|
||
switch (type) {
|
||
case TabSpriteType.home:
|
||
_paintBoyHome(canvas, fill, subFill);
|
||
case TabSpriteType.discover:
|
||
_paintBoyDiscover(canvas, fill, subFill);
|
||
case TabSpriteType.profile:
|
||
_paintBoyProfile(canvas, fill, subFill);
|
||
}
|
||
}
|
||
|
||
void _paintBoyHome(Canvas canvas, Paint fill, Paint subFill) {
|
||
final roof = Path();
|
||
roof.moveTo(16, 3);
|
||
roof.cubicTo(14.5, 5, 4, 10, 4, 12);
|
||
roof.lineTo(28, 12);
|
||
roof.cubicTo(28, 10, 17.5, 5, 16, 3);
|
||
roof.close();
|
||
canvas.drawPath(roof, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(5, 12, 22, 14),
|
||
topLeft: const Radius.circular(2),
|
||
topRight: const Radius.circular(2),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
final door = Path();
|
||
door.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(13, 19, 6, 7),
|
||
topLeft: const Radius.circular(1),
|
||
topRight: const Radius.circular(1),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(door, subFill);
|
||
|
||
canvas.drawCircle(const Offset(17.5, 23), 0.6, _fill(alpha: 0.5));
|
||
|
||
_paintBoyHair(canvas, fill);
|
||
_paintFace(canvas, 16, 14);
|
||
|
||
if (isSelected) {
|
||
_paintChimneyHeart(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintBoyDiscover(Canvas canvas, Paint fill, Paint subFill) {
|
||
canvas.drawCircle(const Offset(16, 16), 11, fill);
|
||
|
||
final innerRing = _stroke(1.2, alpha: 0.25);
|
||
canvas.drawCircle(const Offset(16, 16), 7.5, innerRing);
|
||
|
||
final needle = Path();
|
||
needle.moveTo(16, 7);
|
||
needle.cubicTo(15.2, 10, 14.8, 13, 16, 16);
|
||
needle.cubicTo(17.2, 13, 16.8, 10, 16, 7);
|
||
needle.close();
|
||
canvas.drawPath(needle, fill);
|
||
|
||
final needle2 = Path();
|
||
needle2.moveTo(16, 25);
|
||
needle2.cubicTo(15.2, 22, 14.8, 19, 16, 16);
|
||
needle2.cubicTo(17.2, 19, 16.8, 22, 16, 25);
|
||
needle2.close();
|
||
canvas.drawPath(needle2, _fill(alpha: 0.35));
|
||
|
||
canvas.drawCircle(const Offset(16, 16), 1.5, _fill(alpha: 0.5));
|
||
|
||
_paintBoyHair(canvas, fill);
|
||
_paintFace(canvas, 16, 16);
|
||
|
||
if (isSelected) {
|
||
_paintCompassSparks(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintBoyProfile(Canvas canvas, Paint fill, Paint subFill) {
|
||
final head = Path();
|
||
head.addOval(const Rect.fromLTWH(8, 4, 16, 14));
|
||
canvas.drawPath(head, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(7, 17, 18, 10),
|
||
topLeft: const Radius.circular(6),
|
||
topRight: const Radius.circular(6),
|
||
bottomLeft: const Radius.circular(4),
|
||
bottomRight: const Radius.circular(4),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
_paintBoyHair(canvas, fill);
|
||
_paintFace(canvas, 16, 10);
|
||
|
||
if (isSelected) {
|
||
_paintHalo(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintBoyHair(Canvas canvas, Paint fill) {
|
||
final hair = Path();
|
||
hair.moveTo(8, 9);
|
||
hair.cubicTo(8, 3, 24, 3, 24, 9);
|
||
hair.cubicTo(22, 6, 20, 5, 16, 5.5);
|
||
hair.cubicTo(12, 5, 10, 6, 8, 9);
|
||
hair.close();
|
||
canvas.drawPath(hair, _fill(alpha: 0.65));
|
||
|
||
final bangs = Path();
|
||
bangs.moveTo(10, 8);
|
||
bangs.cubicTo(11, 5, 14, 4.5, 15, 6);
|
||
bangs.cubicTo(15.5, 7, 14, 8, 13, 8.5);
|
||
bangs.lineTo(10, 8);
|
||
bangs.close();
|
||
canvas.drawPath(bangs, _fill(alpha: 0.45));
|
||
}
|
||
|
||
// ============================================================
|
||
// 女孩造型
|
||
// ============================================================
|
||
void _paintGirl(Canvas canvas) {
|
||
final fill = _fill();
|
||
final subFill = _subFill();
|
||
|
||
switch (type) {
|
||
case TabSpriteType.home:
|
||
_paintGirlHome(canvas, fill, subFill);
|
||
case TabSpriteType.discover:
|
||
_paintGirlDiscover(canvas, fill, subFill);
|
||
case TabSpriteType.profile:
|
||
_paintGirlProfile(canvas, fill, subFill);
|
||
}
|
||
}
|
||
|
||
void _paintGirlHome(Canvas canvas, Paint fill, Paint subFill) {
|
||
final roof = Path();
|
||
roof.moveTo(16, 3);
|
||
roof.cubicTo(14.5, 5, 4, 10, 4, 12);
|
||
roof.lineTo(28, 12);
|
||
roof.cubicTo(28, 10, 17.5, 5, 16, 3);
|
||
roof.close();
|
||
canvas.drawPath(roof, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(5, 12, 22, 14),
|
||
topLeft: const Radius.circular(2),
|
||
topRight: const Radius.circular(2),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
final door = Path();
|
||
door.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(13, 19, 6, 7),
|
||
topLeft: const Radius.circular(1),
|
||
topRight: const Radius.circular(1),
|
||
bottomLeft: const Radius.circular(3),
|
||
bottomRight: const Radius.circular(3),
|
||
),
|
||
);
|
||
canvas.drawPath(door, subFill);
|
||
|
||
canvas.drawCircle(const Offset(17.5, 23), 0.6, _fill(alpha: 0.5));
|
||
|
||
_paintGirlHair(canvas, fill);
|
||
_paintFace(canvas, 16, 14);
|
||
|
||
if (isSelected) {
|
||
_paintChimneyHeart(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintGirlDiscover(Canvas canvas, Paint fill, Paint subFill) {
|
||
canvas.drawCircle(const Offset(16, 16), 11, fill);
|
||
|
||
final innerRing = _stroke(1.2, alpha: 0.25);
|
||
canvas.drawCircle(const Offset(16, 16), 7.5, innerRing);
|
||
|
||
final needle = Path();
|
||
needle.moveTo(16, 7);
|
||
needle.cubicTo(15.2, 10, 14.8, 13, 16, 16);
|
||
needle.cubicTo(17.2, 13, 16.8, 10, 16, 7);
|
||
needle.close();
|
||
canvas.drawPath(needle, fill);
|
||
|
||
final needle2 = Path();
|
||
needle2.moveTo(16, 25);
|
||
needle2.cubicTo(15.2, 22, 14.8, 19, 16, 16);
|
||
needle2.cubicTo(17.2, 19, 16.8, 22, 16, 25);
|
||
needle2.close();
|
||
canvas.drawPath(needle2, _fill(alpha: 0.35));
|
||
|
||
canvas.drawCircle(const Offset(16, 16), 1.5, _fill(alpha: 0.5));
|
||
|
||
_paintGirlHair(canvas, fill);
|
||
_paintFace(canvas, 16, 16);
|
||
|
||
if (isSelected) {
|
||
_paintCompassSparks(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintGirlProfile(Canvas canvas, Paint fill, Paint subFill) {
|
||
final head = Path();
|
||
head.addOval(const Rect.fromLTWH(8, 4, 16, 14));
|
||
canvas.drawPath(head, fill);
|
||
|
||
final body = Path();
|
||
body.addRRect(
|
||
RRect.fromRectAndCorners(
|
||
const Rect.fromLTWH(7, 17, 18, 10),
|
||
topLeft: const Radius.circular(6),
|
||
topRight: const Radius.circular(6),
|
||
bottomLeft: const Radius.circular(4),
|
||
bottomRight: const Radius.circular(4),
|
||
),
|
||
);
|
||
canvas.drawPath(body, fill);
|
||
|
||
_paintGirlHair(canvas, fill);
|
||
_paintFace(canvas, 16, 10);
|
||
|
||
if (isSelected) {
|
||
_paintHalo(canvas);
|
||
}
|
||
}
|
||
|
||
void _paintGirlHair(Canvas canvas, Paint fill) {
|
||
final hair = Path();
|
||
hair.moveTo(7, 10);
|
||
hair.cubicTo(6, 3, 26, 3, 25, 10);
|
||
hair.cubicTo(24, 6, 20, 4, 16, 4.5);
|
||
hair.cubicTo(12, 4, 8, 6, 7, 10);
|
||
hair.close();
|
||
canvas.drawPath(hair, _fill(alpha: 0.65));
|
||
|
||
final leftSide = Path();
|
||
leftSide.moveTo(7, 10);
|
||
leftSide.cubicTo(6, 14, 5.5, 20, 6, 25);
|
||
leftSide.cubicTo(6.5, 26, 8, 25.5, 8.5, 24);
|
||
leftSide.cubicTo(8.5, 18, 8, 13, 8, 10);
|
||
leftSide.close();
|
||
canvas.drawPath(leftSide, _fill(alpha: 0.5));
|
||
|
||
final rightSide = Path();
|
||
rightSide.moveTo(25, 10);
|
||
rightSide.cubicTo(26, 14, 26.5, 20, 26, 25);
|
||
rightSide.cubicTo(25.5, 26, 24, 25.5, 23.5, 24);
|
||
rightSide.cubicTo(23.5, 18, 24, 13, 24, 10);
|
||
rightSide.close();
|
||
canvas.drawPath(rightSide, _fill(alpha: 0.5));
|
||
|
||
final bangs = Path();
|
||
bangs.moveTo(9, 9);
|
||
bangs.cubicTo(10, 5, 14, 4, 16, 5);
|
||
bangs.cubicTo(18, 4, 22, 5, 23, 9);
|
||
bangs.cubicTo(21, 7, 18, 6, 16, 7);
|
||
bangs.cubicTo(14, 6, 11, 7, 9, 9);
|
||
bangs.close();
|
||
canvas.drawPath(bangs, _fill(alpha: 0.4));
|
||
}
|
||
|
||
// ============================================================
|
||
// 共享表情系统
|
||
// ============================================================
|
||
void _paintFace(Canvas canvas, double cx, double cy) {
|
||
_paintEyes(canvas, cx, cy);
|
||
_paintMouth(canvas, cx, cy);
|
||
_paintBlush(canvas, cx, cy);
|
||
}
|
||
|
||
void _paintEyes(Canvas canvas, double cx, double cy) {
|
||
final eyeY = cy - 1;
|
||
final lookDx = lookOffset * 1.2;
|
||
final leftX = cx - 3.5 + lookDx;
|
||
final rightX = cx + 3.5 + lookDx;
|
||
|
||
if (expressionProgress > 0.5) {
|
||
final openR = 1.2 * (1.0 + (eyeScale - 1.0) * expressionProgress * 0.5);
|
||
final eyeFill = _fill();
|
||
|
||
canvas.drawOval(
|
||
Rect.fromCenter(
|
||
center: Offset(leftX, eyeY),
|
||
width: openR * 2,
|
||
height: openR * 2.2,
|
||
),
|
||
eyeFill,
|
||
);
|
||
canvas.drawOval(
|
||
Rect.fromCenter(
|
||
center: Offset(rightX, eyeY),
|
||
width: openR * 2,
|
||
height: openR * 2.2,
|
||
),
|
||
eyeFill,
|
||
);
|
||
|
||
final highlight = Paint()
|
||
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.7)
|
||
..style = PaintingStyle.fill;
|
||
canvas.drawCircle(
|
||
Offset(leftX + openR * 0.3, eyeY - openR * 0.4),
|
||
openR * 0.35,
|
||
highlight,
|
||
);
|
||
canvas.drawCircle(
|
||
Offset(rightX + openR * 0.3, eyeY - openR * 0.4),
|
||
openR * 0.35,
|
||
highlight,
|
||
);
|
||
|
||
final smallHighlight = Paint()
|
||
..color = const Color(0xFFFFFFFF).withValues(alpha: 0.4)
|
||
..style = PaintingStyle.fill;
|
||
canvas.drawCircle(
|
||
Offset(leftX - openR * 0.2, eyeY + openR * 0.3),
|
||
openR * 0.18,
|
||
smallHighlight,
|
||
);
|
||
canvas.drawCircle(
|
||
Offset(rightX - openR * 0.2, eyeY + openR * 0.3),
|
||
openR * 0.18,
|
||
smallHighlight,
|
||
);
|
||
} else {
|
||
final closedStroke = _stroke(1.2);
|
||
final closeProgress = 1.0 - expressionProgress * 2.0;
|
||
final eyeW = 2.0 * closeProgress;
|
||
|
||
final leftPath = Path();
|
||
leftPath.moveTo(leftX - eyeW, eyeY);
|
||
leftPath.cubicTo(
|
||
leftX - eyeW * 0.5,
|
||
eyeY + 0.5,
|
||
leftX + eyeW * 0.5,
|
||
eyeY + 0.5,
|
||
leftX + eyeW,
|
||
eyeY,
|
||
);
|
||
canvas.drawPath(leftPath, closedStroke);
|
||
|
||
final rightPath = Path();
|
||
rightPath.moveTo(rightX - eyeW, eyeY);
|
||
rightPath.cubicTo(
|
||
rightX - eyeW * 0.5,
|
||
eyeY + 0.5,
|
||
rightX + eyeW * 0.5,
|
||
eyeY + 0.5,
|
||
rightX + eyeW,
|
||
eyeY,
|
||
);
|
||
canvas.drawPath(rightPath, closedStroke);
|
||
}
|
||
}
|
||
|
||
void _paintMouth(Canvas canvas, double cx, double cy) {
|
||
final mouthY = cy + 2.5;
|
||
final mouthW = 2.0 + mouthCurve * expressionProgress * 1.0;
|
||
final curveH = 1.5 * mouthCurve * expressionProgress;
|
||
|
||
if (expressionProgress < 0.1) {
|
||
final neutralStroke = _stroke(1.0, alpha: 0.6);
|
||
canvas.drawLine(
|
||
Offset(cx - 1.5, mouthY),
|
||
Offset(cx + 1.5, mouthY),
|
||
neutralStroke,
|
||
);
|
||
} else if (expressionProgress > 0.7 && mouthCurve > 0.5) {
|
||
final mouthPath = Path();
|
||
mouthPath.moveTo(cx - mouthW, mouthY);
|
||
mouthPath.cubicTo(
|
||
cx - mouthW * 0.5,
|
||
mouthY + curveH * 1.8,
|
||
cx + mouthW * 0.5,
|
||
mouthY + curveH * 1.8,
|
||
cx + mouthW,
|
||
mouthY,
|
||
);
|
||
mouthPath.lineTo(cx - mouthW, mouthY);
|
||
mouthPath.close();
|
||
canvas.drawPath(mouthPath, _fill(alpha: 0.35));
|
||
|
||
final mouthStroke = Path();
|
||
mouthStroke.moveTo(cx - mouthW, mouthY);
|
||
mouthStroke.cubicTo(
|
||
cx - mouthW * 0.5,
|
||
mouthY + curveH * 1.8,
|
||
cx + mouthW * 0.5,
|
||
mouthY + curveH * 1.8,
|
||
cx + mouthW,
|
||
mouthY,
|
||
);
|
||
canvas.drawPath(mouthStroke, _stroke(1.0));
|
||
} else {
|
||
final mouthPath = Path();
|
||
mouthPath.moveTo(cx - mouthW, mouthY);
|
||
mouthPath.cubicTo(
|
||
cx - mouthW * 0.3,
|
||
mouthY + curveH,
|
||
cx + mouthW * 0.3,
|
||
mouthY + curveH,
|
||
cx + mouthW,
|
||
mouthY,
|
||
);
|
||
canvas.drawPath(mouthPath, _stroke(1.0));
|
||
}
|
||
}
|
||
|
||
void _paintBlush(Canvas canvas, double cx, double cy) {
|
||
if (expressionProgress < 0.6 || !isSelected) return;
|
||
final blushAlpha = (expressionProgress - 0.6) * 2.5 * 0.2;
|
||
final blushPaint = Paint()
|
||
..color = color.withValues(alpha: blushAlpha.clamp(0.0, 0.2))
|
||
..style = PaintingStyle.fill
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5);
|
||
|
||
canvas.drawOval(
|
||
Rect.fromCenter(
|
||
center: Offset(cx - 5.5, cy + 1.5),
|
||
width: 3.5,
|
||
height: 2,
|
||
),
|
||
blushPaint,
|
||
);
|
||
canvas.drawOval(
|
||
Rect.fromCenter(
|
||
center: Offset(cx + 5.5, cy + 1.5),
|
||
width: 3.5,
|
||
height: 2,
|
||
),
|
||
blushPaint,
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 选中特效
|
||
// ============================================================
|
||
void _paintChimneyHeart(Canvas canvas) {
|
||
final heartPaint = _fill(alpha: 0.55);
|
||
const hx = 22.0, hy = 5.0, hs = 2.0;
|
||
final heart = Path();
|
||
heart.moveTo(hx, hy + hs * 0.3);
|
||
heart.cubicTo(
|
||
hx - hs,
|
||
hy - hs * 0.3,
|
||
hx - hs * 0.5,
|
||
hy - hs,
|
||
hx,
|
||
hy - hs * 0.4,
|
||
);
|
||
heart.cubicTo(
|
||
hx + hs * 0.5,
|
||
hy - hs,
|
||
hx + hs,
|
||
hy - hs * 0.3,
|
||
hx,
|
||
hy + hs * 0.3,
|
||
);
|
||
canvas.drawPath(heart, heartPaint);
|
||
}
|
||
|
||
void _paintCompassSparks(Canvas canvas) {
|
||
final sparkPaint = _fill(alpha: 0.55);
|
||
for (var i = 0; i < 4; i++) {
|
||
final angle = i * math.pi / 2 + 0.4;
|
||
final sx = 16 + 9.5 * math.cos(angle);
|
||
final sy = 16 + 9.5 * math.sin(angle);
|
||
final spark = Path();
|
||
spark.moveTo(sx, sy - 1);
|
||
spark.lineTo(sx + 0.3, sy);
|
||
spark.lineTo(sx, sy + 1);
|
||
spark.lineTo(sx - 0.3, sy);
|
||
spark.close();
|
||
canvas.drawPath(spark, sparkPaint);
|
||
}
|
||
}
|
||
|
||
void _paintHalo(Canvas canvas) {
|
||
final haloPaint = Paint()
|
||
..color = color.withValues(alpha: 0.3)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.2;
|
||
canvas.drawOval(
|
||
Rect.fromCenter(center: const Offset(16, 3.5), width: 8, height: 2.5),
|
||
haloPaint,
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 画笔工具
|
||
// ============================================================
|
||
Paint _fill({double alpha = 1.0}) {
|
||
return Paint()
|
||
..color = color.withValues(alpha: alpha)
|
||
..style = PaintingStyle.fill
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round
|
||
..isAntiAlias = true;
|
||
}
|
||
|
||
Paint _subFill() {
|
||
return Paint()
|
||
..color = color.withValues(alpha: 0.3)
|
||
..style = PaintingStyle.fill
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round
|
||
..isAntiAlias = true;
|
||
}
|
||
|
||
Paint _stroke(double width, {double alpha = 1.0}) {
|
||
return Paint()
|
||
..color = color.withValues(alpha: alpha)
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = width
|
||
..strokeCap = StrokeCap.round
|
||
..strokeJoin = StrokeJoin.round
|
||
..isAntiAlias = true;
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_RefinedSpritePainter oldDelegate) {
|
||
return expressionProgress != oldDelegate.expressionProgress ||
|
||
lookOffset != oldDelegate.lookOffset ||
|
||
isSelected != oldDelegate.isSelected ||
|
||
characterId != oldDelegate.characterId ||
|
||
color != oldDelegate.color;
|
||
}
|
||
}
|