앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user