Files
fanuzu/app/lib/core/ui/planet_evolution.dart
T

510 lines
15 KiB
Dart

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<String>? heroEventIds;
final bool heroEnabled;
final int? stage;
final int? level;
final bool animate;
final bool lowEffects;
final Widget Function(int? stage) builder;
@override
State<PlanetEvolution> createState() => _PlanetEvolutionState();
}
class _PlanetEvolutionState extends State<PlanetEvolution>
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 = <String>{};
bool _heroReady = false;
bool _hero = false;
bool _heroEnding = false;
bool _pendingHeroEnd = false;
final _pendingHero = <String>{};
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 <String>{}).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;
}