510 lines
15 KiB
Dart
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;
|
|
}
|