import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../theme/tokens.dart'; import 'planet_fx_scope.dart'; import '../i18n/app_locale.dart'; /// v1.5 §2.1/6. 진화·HERO 발동은 하나의 타임라인을 쓰며 첫 조회는 기준점이다. class PlanetEvolution extends StatefulWidget { const PlanetEvolution({ super.key, required this.stage, required this.level, required this.animate, required this.lowEffects, required this.builder, this.heroEventIds, this.onOpenHistory, this.heroEnabled = true, }); final VoidCallback? onOpenHistory; final Set? heroEventIds; final bool heroEnabled; final int? stage; final int? level; final bool animate; final bool lowEffects; final Widget Function(int? stage) builder; @override State createState() => _PlanetEvolutionState(); } class _PlanetEvolutionState extends State with SingleTickerProviderStateMixin { late final _fx = AnimationController(vsync: this) ..addStatusListener((status) { if (status == AnimationStatus.completed) setState(() { if (_evolution && !_hero) _completedEvolution = true; _active = false; _shownStage = widget.stage; if (_pendingHero.isNotEmpty && _canHero && widget.animate && TickerMode.valuesOf(context).enabled) { _pendingHero.clear(); _start(false, hero: true); } else if (_pendingHeroEnd && !_canHero && widget.heroEnabled && widget.animate && TickerMode.valuesOf(context).enabled) { _pendingHeroEnd = false; _start(false, hero: true, ending: true); } else { _activity?.setActive(this, false); } }); }); PlanetFxActivity? _activity; @override void didChangeDependencies() { super.didChangeDependencies(); if (_active && !TickerMode.valuesOf(context).enabled) { _fx.stop(); _pendingHero.clear(); _pendingHeroEnd = false; _activity?.setActive(this, false); _active = false; _shownStage = widget.stage; } final next = PlanetFxScope.of(context); if (!identical(next, _activity)) { _activity?.setActive(this, false); _activity = next; _activity?.setActive(this, _active && (_evolution || _hero)); } } int? _highestStage; int? _highestLevel; int? _shownStage; @override void initState() { super.initState(); _highestStage = widget.stage; _highestLevel = widget.level; _shownStage = widget.stage; _heroReady = widget.heroEventIds != null; _seenHeroIds.addAll(widget.heroEventIds ?? const {}); } final _seenHeroIds = {}; bool _heroReady = false; bool _hero = false; bool _heroEnding = false; bool _pendingHeroEnd = false; final _pendingHero = {}; bool get _canHero => widget.heroEnabled && (widget.heroEventIds?.isNotEmpty ?? false); bool _active = false; bool _evolution = false; bool _completedEvolution = false; bool get _reduced => MediaQuery.disableAnimationsOf(context); @override void didUpdateWidget(PlanetEvolution old) { super.didUpdateWidget(old); final stage = widget.stage; final level = widget.level; final evolved = stage != null && old.stage != null && _highestStage != null && stage > _highestStage!; final levelled = level != null && old.level != null && _highestLevel != null && level > _highestLevel!; if (stage != null) _highestStage = math.max(stage, _highestStage ?? stage); if (level != null) _highestLevel = math.max(level, _highestLevel ?? level); if (old.stage != null && (stage == null || stage < old.stage!)) { _completedEvolution = false; _fx.stop(); _activity?.setActive(this, false); _active = false; _shownStage = stage; } final ids = widget.heroEventIds; final heroEnded = old.heroEventIds?.isNotEmpty == true && ids?.isEmpty == true; if (_canHero || !widget.heroEnabled) _pendingHeroEnd = false; if (_active && _heroEnding && (_canHero || !widget.heroEnabled)) { _fx.stop(); _activity?.setActive(this, false); _active = false; } final newHeroIds = (ids ?? const {}).difference(_seenHeroIds); _pendingHero.retainAll(ids ?? const {}); final newHero = ids != null && _heroReady && newHeroIds.isNotEmpty; if (ids != null) { _seenHeroIds.addAll(ids); _heroReady = true; } if (!_canHero) { _pendingHero.clear(); if (_active && _hero && !_heroEnding) { _fx.stop(); _activity?.setActive(this, false); _active = false; } } final visible = widget.animate && TickerMode.valuesOf(context).enabled; if (!visible) { _fx.stop(); _activity?.setActive(this, false); _active = false; } if (newHero && visible && _canHero) _pendingHero.addAll(newHeroIds); if (!visible) { _pendingHero.clear(); _pendingHeroEnd = false; } if (heroEnded && visible && widget.heroEnabled) _pendingHeroEnd = true; if (evolved && visible) { // 같은 화면에서 Evolution 중 추가 승급은 최종 Stage로 합쳐 강한 FX를 겹치지 않는다. if (!_active || !_evolution) _start(true); } else if (_pendingHeroEnd && visible && !(_active && _evolution)) { _pendingHeroEnd = false; _start(false, hero: true, ending: true); } else if (_pendingHero.isNotEmpty && visible && !(_active && (_evolution || _hero))) { _pendingHero.clear(); _start(false, hero: true); } else if (levelled && visible && !(_active && (_evolution || _hero))) { _start(false); } if (!_active) _shownStage = stage; } void _start(bool evolution, {bool hero = false, bool ending = false}) { if (evolution) _completedEvolution = false; _hero = hero; _heroEnding = ending; _activity?.setActive(this, evolution || hero); _evolution = evolution; _active = true; _fx.duration = Duration( milliseconds: _reduced ? 1200 : ending ? 1100 : hero ? 2400 : evolution ? 3200 : 850, ); _fx.forward(from: 0); if (ending) return; if (evolution || hero) { HapticFeedback.heavyImpact(); } else { HapticFeedback.mediumImpact(); } } @override void dispose() { _activity?.setActive(this, false); _fx.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AnimatedBuilder( animation: _fx, builder: (context, _) { final p = _fx.value; final stage = !_active || !_evolution || _reduced || p >= 0.48 ? widget.stage : _shownStage; final l10n = context.l10n; final names = l10n.stageNames.split(','); final stageLabel = widget.stage != null && widget.stage! >= 0 && widget.stage! <= 6 ? 'Stage ${widget.stage} · ${names[widget.stage!]}' : l10n.evoPlanetGrowth; final label = _heroEnding ? l10n.evoHeroEnd : _hero ? (_reduced || p >= 0.5 ? 'HERO TIME' : l10n.evoAwakening) : !_evolution ? 'Lv.${widget.level}' : _reduced ? stageLabel : p < 0.16 ? l10n.evoAnomaly : p < 0.36 ? l10n.evoEnergy : p < 0.48 ? l10n.evoTransition : p < 0.70 ? stageLabel : l10n.evoComplete; return Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ Opacity( opacity: _active && _hero && !_heroEnding && !_reduced ? (p < 0.18 ? 1 - p / 0.18 * 0.3 : p < 0.5 ? 0.7 + (p - 0.18) / 0.32 * 0.3 : 1) : 1, child: widget.builder(stage), ), if (_active && !_reduced) Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _hero ? _HeroPainter( progress: p, lowEffects: widget.lowEffects, ending: _heroEnding, ) : _EvolutionPainter( progress: p, evolution: _evolution, lowEffects: widget.lowEffects, ), ), ), ), if (!_active && _completedEvolution && widget.onOpenHistory != null) Positioned(bottom: 0, left: 0, right: 0, child: Center(child: TextButton( onPressed: () { setState(() => _completedEvolution = false); widget.onOpenHistory!(); }, child: Text(l10n.evoOpenHistory), )), ), if (_active) Positioned( bottom: 0, left: -30, right: -30, child: IgnorePointer( child: Semantics( liveRegion: true, child: Center( child: DecoratedBox( decoration: BoxDecoration( color: AppColors.cosmicSurface.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), child: Text( label, textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelMedium ?.copyWith(color: AppColors.onCosmic), ), ), ), ), ), ), ), ], ); }, ); } class _EvolutionPainter extends CustomPainter { const _EvolutionPainter({ required this.progress, required this.evolution, required this.lowEffects, }); final double progress; final bool evolution; final bool lowEffects; @override void paint(Canvas canvas, Size size) { final c = size.center(Offset.zero); final radius = size.shortestSide * 0.42; const color = AppColors.cosmicAccent; final p = progress; if (!evolution) { canvas.drawCircle( c, radius * (1 + p * 0.5), Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 ..color = color.withValues(alpha: (1 - p) * 0.8), ); canvas.drawCircle( c, radius * 1.4, Paint() ..shader = RadialGradient( colors: [ color.withValues(alpha: (1 - p) * 0.25), color.withValues(alpha: 0), ], ).createShader(Rect.fromCircle(center: c, radius: radius * 1.4)), ); return; } if (p < 0.16) return; if (p < 0.48) { final focus = ((p - 0.16) / 0.20).clamp(0.0, 1.0); final r = radius * (1.5 - focus * 1.3); canvas.drawCircle( c, r, Paint() ..shader = RadialGradient( colors: [ Colors.white.withValues(alpha: 0.85 * focus), color.withValues(alpha: 0.4), color.withValues(alpha: 0), ], ).createShader(Rect.fromCircle(center: c, radius: r)), ); for (var i = 0; i < (lowEffects ? 6 : 16); i++) { final a = i * 2.4 + p * 5; canvas.drawCircle( c + Offset(math.cos(a), math.sin(a)) * r, 2, Paint()..color = color.withValues(alpha: 0.8), ); } } else if (p >= 0.70) { final burst = (p - 0.70) / 0.30; for (var i = 0; i < (lowEffects ? 8 : 28); i++) { final x = ((i * 0.618) % 1 - 0.5) * radius * 3; final y = -radius + ((i * 0.37) % 1 + burst) * radius * 1.3; canvas.drawCircle( c + Offset(x, y), 1.5 + (i % 3) * 0.5, Paint() ..color = Color.lerp( color, Colors.white, (i % 3) / 2, )!.withValues(alpha: 1 - burst), ); } } } @override bool shouldRepaint(_EvolutionPainter old) => old.progress != progress || old.evolution != evolution || old.lowEffects != lowEffects; } /// 짧은 광원 집중→코어 이동→Ring Wave. 본체 반경 안팎에서 끝낸다. class _HeroPainter extends CustomPainter { const _HeroPainter({ required this.progress, required this.lowEffects, this.ending = false, }); final double progress; final bool ending; final bool lowEffects; @override void paint(Canvas canvas, Size size) { final c = size.center(Offset.zero); final r = size.shortestSide * 0.46; const color = AppColors.gold; final p = progress; if (ending) { final remaining = 1 - Curves.easeInCubic.transform(p); final count = lowEffects ? 8 : 24; for (var i = 0; i < count; i++) { final a = i * 2.399 + p * 0.4; final reach = r * remaining * (0.7 + (i % 4) * 0.15); canvas.drawCircle( c + Offset(math.cos(a), math.sin(a) * 0.7) * reach, 1.2 + i % 3 * 0.4, Paint()..color = color.withValues(alpha: (1 - p) * 0.65), ); } return; } if (p < 0.5) { final travel = ((p - 0.18) / 0.32).clamp(0.0, 1.0); final point = Offset.lerp( c + Offset(-r * 0.7, -r), c, Curves.easeInCubic.transform(travel), )!; final radius = r * (0.10 + travel * 0.10); canvas.drawCircle( point, radius * 2, Paint() ..shader = RadialGradient( colors: [ Colors.white.withValues(alpha: 0.85), color.withValues(alpha: 0.6), color.withValues(alpha: 0), ], stops: const [0, 0.2, 1], ).createShader(Rect.fromCircle(center: point, radius: radius * 2)), ); } else { final burst = (p - 0.5) * 2; final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 * (1 - burst) + 0.5 ..color = color.withValues(alpha: (1 - burst) * 0.65); canvas.save(); canvas.translate(c.dx, c.dy); canvas.rotate(-0.32); for (var i = 0; i < 2; i++) { canvas.drawOval( Rect.fromCenter( center: Offset.zero, width: r * (0.8 + burst * 2.2 - i * 0.15), height: r * (0.3 + burst - i * 0.05), ), paint, ); } canvas.restore(); final count = lowEffects ? 8 : 24; for (var i = 0; i < count; i++) { final a = i * 2.399; final reach = r * (0.3 + burst * (0.8 + (i % 3) * 0.12)); canvas.drawCircle( c + Offset(math.cos(a), math.sin(a) * 0.7) * reach, 1 + i % 3 * 0.4, Paint()..color = color.withValues(alpha: (1 - burst) * 0.8), ); } } } @override bool shouldRepaint(_HeroPainter old) => old.progress != progress || old.lowEffects != lowEffects || old.ending != ending; }