import 'dart:math' as math; import 'package:flutter/material.dart'; import '../theme/tokens.dart'; import 'planet_evolution.dart'; import 'planet_fx_scope.dart'; import 'adaptive_planet_effects.dart'; import '../i18n/app_locale.dart'; /// 행성 컬러 팔레트 — 사본 「행성 색 선택」 `373:358685` ~ `373:358692` 순서. /// 생성 화면이 고르는 값과 렌더링이 읽는 값이 **같은 표**여야 하므로 여기 한 곳에 둔다(C-50). /// 흰 = 색 선택 명세(`2066:17910`)의 #FFFFFF — 해석 확인 요청 Q-PN-01 권고 B안(칩의 #D9D9D9 는 /// Figma 기본 채움). 저장값은 키(`white`)라 값만 바꾸면 기존 행성도 따라온다. const planetColors = <(String, Color)>[ ('white', Color(0xFFFFFFFF)), ('black', Color(0xFF000000)), ('blue', Color(0xFF009CFE)), ('green', Color(0xFF00C60C)), ('gold', Color(0xFFFFC500)), ('orange', Color(0xFFFF7D00)), ('red', Color(0xFFFF0049)), ('purple', Color(0xFFC300C8)), ]; /// 컬러 마스터 `config/planetColors` 에서 읽은 **전체** 목록(숨긴 색 포함 — 이미 그 색인 행성을 그려야 한다). /// 10/3 통합회신 #6 「발주사가 컬러 항목을 추가할 수 있는 구조 · 8색 고정 적용 안 함」. 읽기 전에는 [planetColors]. List<(String, Color)> _planetColorMaster = planetColors; /// `config/planetColors` 문서 → (그리기용 전체, 생성 화면에 내놓을 노출 목록). 형식이 틀린 항목은 건너뛴다. /// 읽을 수 있는 항목이 없으면 기본 목록([planetColors]). ({List<(String, Color)> all, List<(String, Color)> visible}) planetColorsFrom( Map? doc, ) { final all = <(String, Color)>[]; final visible = <(String, Color)>[]; final raw = doc?['colors']; if (raw is List) { for (final c in raw) { if (c is! Map) continue; final key = c['key']; final hex = c['hex']; final m = hex is String ? RegExp(r'^#?([0-9a-fA-F]{6})$').firstMatch(hex) : null; if (key is! String || key.isEmpty || m == null) continue; final entry = (key, Color(0xFF000000 | int.parse(m.group(1)!, radix: 16))); all.add(entry); if (c['visible'] != false) visible.add(entry); } } return visible.isEmpty ? (all: [...all, ...planetColors], visible: planetColors) : (all: all, visible: visible); } /// 마스터를 읽은 뒤 그리기 목록을 바꾼다. void setPlanetColorMaster(List<(String, Color)> all) => _planetColorMaster = all; /// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로. Color? planetColorOf(String? key) => _planetColorMaster.where((c) => c.$1 == key).firstOrNull?.$2 ?? planetColors.where((c) => c.$1 == key).firstOrNull?.$2; /// 생성 때 고른 형태(`planetType`, 서버 `PLANET_SHAPES`) → 형태 그림. 모르는 값 · 성운이면 null. /// /// 그림은 Figma 「홈_성장 완료/행성 생성」(`344:214421`) 형태 칸이 쓰는 **행성 형태 시트 이미지 /// (1536×1024 · 투명 배경 · 2행 5열)** 에서 칸과 같은 형태를 잘라 낸 것이다(2026-10-06 재추출). /// 칸 1~6 은 시트 1 · 2 · 3 · 4 · 10 · 8번째 형태다. 6번째 칸(`2154:16037`)은 화면에서 「다음」 버튼에 /// 가려 보이지 않지만, 노드의 이미지 잘라내기 값(시트 x 0.383 · y 0.480)으로 8번째 형태임을 확인했다. /// 시트 원본이 형태당 약 260px 라 큰 화면에서는 흐릴 수 있다 — 사본 Figma 에 더 큰 원본이 없다(Q-A14 ②). String? planetShapeImage(String? planetType) { final n = RegExp(r'^shape([1-6])$').firstMatch(planetType ?? '')?.group(1); return n == null ? null : 'assets/figma/planet-shape-$n.png'; } /// 형태(`planetType`)가 없는 행성 · 성운의 그림 — **앱 전체가 이 한 함수로 고른다.** 화면마다 /// 따로 고르면 같은 옛 행성이 내 행성 격자에서는 파란 행성, 검색 · 상세에서는 보라 행성으로 /// 달라진다(10/6 실기기 C-5). 성운은 성운 그림, 행성은 시안 행성 그림 두 장 중 id 로 하나. // ponytail: 형태 없는 옛 데이터용 고정 두 그림. 모든 행성에 형태가 생기면 지운다. String planetFallbackImage(String id, {bool nebula = false}) => nebula ? 'assets/figma/nebula-card.png' : id.codeUnits.fold(0, (a, c) => a + c).isEven ? 'assets/figma/planet-detail-hero.png' : 'assets/figma/planet-earth.png'; /// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다. /// 색은 [Planet25D] 와 같은 곱하기 합성이다. class PlanetArt extends StatelessWidget { const PlanetArt({ super.key, required this.fallback, this.planetType, this.colorKey, this.width, this.height, this.semanticLabel, }); final String fallback; final String? planetType; final String? colorKey; final double? width; final double? height; final String? semanticLabel; @override Widget build(BuildContext context) { final shape = planetShapeImage(planetType); final image = Image.asset( shape ?? fallback, width: width, height: height, fit: BoxFit.contain, semanticLabel: semanticLabel, ); final tint = shape == null ? null : planetColorOf(colorKey); return tint == null ? image : ColorFiltered( colorFilter: ColorFilter.mode(tint, BlendMode.modulate), child: image, ); } } /// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다. /// Stage는 서버 확정값이며, null은 단계 데이터 미제공 상태다. class Planet25D extends StatefulWidget { const Planet25D({ super.key, required this.image, required this.size, this.glow = AppColors.neonViolet, this.tint, this.stage, this.level, this.evolutionImminent = false, this.isEclipsed = false, this.eclipseFailed = false, this.eclipseSuccessId, this.eclipseWarnAt, this.eclipseStartAt, this.isHibernating = false, this.isHeroTime = false, this.heroEventIds, this.onOpenHistory, this.lowEffects = false, this.animate = true, }); /// 다음 Stage 진화 5% 전 — 표면 아우라([PlanetVisualState.evolutionImminent]). final bool evolutionImminent; /// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`). final VoidCallback? onOpenHistory; final String image; /// 한 변 길이. 글로우와 링은 이 바깥으로 조금 번진다. final double size; /// 아우라 색. final Color glow; /// 생성 때 고른 컬러([planetColorOf]). 생성 화면 미리보기와 같은 곱하기 합성으로 본체에 입힌다. final Color? tint; final int? stage; final int? level; final bool isEclipsed; /// 이클립스 실패 확정 — 파괴 표현 없이 밝기 · Glow · Particle 만 줄인다(v1.5 §5). final bool eclipseFailed; /// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」). final String? eclipseSuccessId; /// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 원장 C-22 발생 3분 전). 이 사이에만 /// v1.5 §5 「행성 뒤 어두운 그림자 접근, 별빛·Glow 점진 감소」를 그린다([eclipseApproach]). final DateTime? eclipseWarnAt; final DateTime? eclipseStartAt; final bool isHibernating; final bool isHeroTime; final Set? heroEventIds; /// 저사양에서는 상태를 유지하면서 입자·Blur만 축소한다. final bool lowEffects; /// 정지 화면(목록 카드 등)에서는 끈다. final bool animate; /// 전역 스위치. 위젯 테스트는 `pumpAndSettle` 이 끝나야 하므로 끈다 /// (`test/flutter_test_config.dart`). 앱에서는 항상 켜져 있다. static bool animationsEnabled = true; @override State createState() => _Planet25DState(); } class _Planet25DState extends State with TickerProviderStateMixin { int get _quality => widget.lowEffects ? 2 : PlanetEffectsQuality.of(context); bool get _lowEffects => _quality >= 2; bool get _celebrating => widget.isHeroTime && !widget.isEclipsed && !widget.isHibernating; int get _period => widget.isHibernating ? 192 : widget.isEclipsed ? 96 : _celebrating ? 32 : 48; double get _light => widget.isHibernating ? 0.35 : widget.isEclipsed ? eclipseLight : _celebrating ? 1.3 : widget.eclipseFailed // 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목). ? eclipseLight : 1; late final AnimationController _c = AnimationController( vsync: this, duration: Duration(seconds: _period), ); bool get _motionEnabled => widget.animate && Planet25D.animationsEnabled && !MediaQuery.disableAnimationsOf(context); void _syncMotion() { if (_motionEnabled && !_c.isAnimating) _c.repeat(); if (!_motionEnabled) _c.stop(); } @override void didChangeDependencies() { super.didChangeDependencies(); _fx = PlanetFxScope.of(context); _syncMotion(); } @override void didUpdateWidget(Planet25D old) { super.didUpdateWidget(old); if (old.isHibernating != widget.isHibernating || old.isEclipsed != widget.isEclipsed || old.isHeroTime != widget.isHeroTime) { _c.duration = Duration(seconds: _period); if (_motionEnabled) _c.repeat(); } _syncMotion(); _maybeBurst(old); } /// 이클립스 성공 연출 — 광량 복원([_restore]) 뒤 Orbit Burst([orbitBurstDuration]). late final AnimationController _burst = AnimationController( vsync: this, duration: _restore + orbitBurstDuration, )..addStatusListener((status) { if (status == AnimationStatus.completed) _fx?.setActive(this, false); }); static const _restore = Duration(seconds: 2); PlanetFxActivity? _fx; void _maybeBurst(Planet25D old) { final id = widget.eclipseSuccessId; if (id == null || id == old.eclipseSuccessId || !_motionEnabled) return; // 한 화면에 강한 연출은 하나 — 진화 · HERO 가 재생 중이면 겹치지 않는다(v1.5 §1.1). if (_fx?.busy ?? false) return; _fx?.setActive(this, true); _burst.forward(from: 0); } @override void dispose() { _fx?.setActive(this, false); _burst.dispose(); _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) => TweenAnimationBuilder( tween: Tween(begin: _light, end: _light), duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(seconds: 2), builder: (context, light, _) => PlanetEvolution( stage: widget.stage, onOpenHistory: widget.onOpenHistory, level: widget.level, heroEventIds: widget.heroEventIds, heroEnabled: !widget.isEclipsed && !widget.isHibernating, animate: widget.animate, lowEffects: _lowEffects, builder: (stage) => Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ Opacity( key: const ValueKey('planet-surface-light'), opacity: light.clamp(0.0, 1.0), child: _planet(context, stage, light), ), if (_burst.isAnimating) Positioned.fill( child: IgnorePointer( child: AnimatedBuilder( animation: _burst, builder: (context, _) { final total = _burst.duration!.inMilliseconds; final p = ((_burst.value * total - _restore.inMilliseconds) / orbitBurstDuration.inMilliseconds) .clamp(0.0, 1.0); return CustomPaint( key: const ValueKey('eclipse-orbit-burst'), painter: _OrbitBurstPainter( p, widget.glow, _lowEffects, ), ); }, ), ), ), ], ), ), ); Widget _tinted(Widget body) => switch (widget.tint) { final tint? => ColorFiltered( colorFilter: ColorFilter.mode(tint, BlendMode.modulate), child: body, ), null => body, }; Widget _planet(BuildContext context, int? stage, double light) { final s = widget.size; final validStage = stage != null && stage >= 0 && stage <= 6; final l10n = context.l10n; final names = l10n.stageNames.split(','); final particleCount = stage == 0 ? 24 : stage == 1 ? 6 : stage == 5 ? 40 : stage == 6 ? 18 : 14; final glowStrength = _quality == 0 ? 1.0 : _quality == 1 ? 0.8 : 0.6; final ring = validStage && stage >= 4; final glow = Color.lerp( widget.glow, AppColors.gold, ((light - 1) / 0.3).clamp(0.0, 1.0), )!; return Semantics( label: '${validStage ? 'Stage $stage · ${names[stage]}' : l10n.planetStageUnknown}${widget.isHibernating ? l10n.planetHibernatingSuffix : widget.isEclipsed ? l10n.planetEclipseSuffix : _celebrating ? l10n.planetHeroSuffix : ''}', image: true, child: RepaintBoundary( child: SizedBox( width: s, height: s, child: AnimatedBuilder( animation: _c, builder: (context, _) { final t = _motionEnabled ? _c.value : 0.0; // 0~1 final wave = math.sin(t * 2 * math.pi); // -1~1 // 사전 경고 — 매 프레임 서버 시각 기준으로 다시 잰다. 동작 줄이기면 프레임이 돌지 않아 // 그린 시점의 위치에 멈춰 있다(움직임 없이 상태만 전달). final warn = widget.isEclipsed ? 0.0 : eclipseApproach(widget.eclipseWarnAt, widget.eclipseStartAt, DateTime.now()) ?? 0.0; // 별빛(입자) · Glow 를 이클립스 진행 중 광량(0.5)까지 점진적으로 낮춘다. final dim = 1 - (1 - eclipseLight) * warn; return Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [ // 2. 글로우 아우라 — 은은하게 커졌다 작아진다. Positioned.fill( child: Transform.scale( scale: (stage == 5 ? 1.42 : 1.25) + (stage == 6 ? 0 : wave * 0.05) + (light - 1).clamp(0.0, 0.3), child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ glow.withValues( alpha: ((stage == 1 ? 0.22 : stage == 5 ? 0.55 : 0.42) + wave * (stage == 6 ? 0.02 : 0.05)) * glowStrength * dim, ), glow.withValues(alpha: 0.10 * glowStrength * dim), glow.withValues(alpha: 0), ], stops: const [0.45, 0.72, 1], ), ), ), ), ), // Stage6 지속 아우라 · 진화 5% 전 표면 아우라(Figma `2024:20758`) — 같은 에셋 레이어를 쓴다. if (stage == 6 || widget.evolutionImminent) Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _SustainedAura( color: glow, lowEffects: _lowEffects, ), ), ), ), // 이클립스 사전 경고 — 행성 뒤로 어두운 그림자가 우상단에서 다가와 Glow 를 가린다. if (warn > 0) Positioned.fill( child: IgnorePointer( child: Transform.translate( key: const ValueKey('eclipse-shadow'), offset: Offset(s, -s * 0.5) * eclipseShadowTravel * (1 - warn), child: Transform.scale( scale: 1.15, child: Opacity( opacity: 0.85 * warn, child: const DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [Color(0xFF05040D), Color(0xCC05040D), Color(0x0005040D)], stops: [0, 0.6, 1], ), ), ), ), ), ), ), ), // 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다. if (ring) Positioned.fill( child: _RingLayer( compact: stage == 4, strength: stage == 5 ? 0.58 : stage == 6 ? 0.52 : 0.48, t: t, color: glow, lowEffects: _lowEffects, front: false, ), ), if (stage == 0) Transform.rotate( angle: t * 2 * math.pi, child: Opacity( opacity: 0.85, child: Image.asset( 'assets/fx/nebula-figma.png', width: s, height: s, fit: BoxFit.contain, ), ), ) else Transform.translate( offset: Offset(0, wave * s * 0.025), // 임시조치: 본체 이미지를 평면 회전시키지 않는다. // 실제 구체 자전은 별도 표면/모델 기반 구현이 필요하다. child: _tinted( Image.asset( widget.image, width: s, height: s, fit: BoxFit.contain, ), ), ), if (validStage && stage >= 2) Positioned.fill( child: IgnorePointer( child: Transform.rotate( angle: -t * 2 * math.pi, child: Opacity( opacity: stage == 2 ? 0.65 : 0.8, child: Image.asset( 'assets/fx/clouds.png', fit: BoxFit.contain, cacheWidth: _lowEffects ? 256 : 512, ), ), ), ), ), if (validStage && stage >= 3) Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _OrbitPainter( t: t, stage: stage, color: glow, lowEffects: _lowEffects, ), ), ), ), // 6. 링 — 앞쪽 절반. 본체를 가려 깊이를 만든다. if (ring) Positioned.fill( child: _RingLayer( compact: stage == 4, strength: stage == 5 ? 0.58 : stage == 6 ? 0.52 : 0.48, t: t, color: glow, lowEffects: _lowEffects, front: true, ), ), // 7. 입자 — 자리마다 위상이 달라 서로 다른 박자로 반짝인다. Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _ParticlePainter( t: t, color: glow, steady: stage == 6, count: (particleCount * light * dim * (_quality == 2 ? 0.3 : _quality == 1 ? 0.6 : 1)) .round() .clamp(2, 60), lowEffects: _lowEffects, ), ), ), ), ], ); }, ), ), ), ); } } /// 원본 보드의 다중 고리/잔해 질감. 뒤→본체→앞으로 합성한다. class _RingLayer extends StatelessWidget { const _RingLayer({ required this.strength, required this.compact, required this.t, required this.color, required this.front, required this.lowEffects, }); final double strength; final bool compact; final double t; final Color color; final bool front; final bool lowEffects; @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, box) { final s = box.maxWidth; return IgnorePointer( child: OverflowBox( maxWidth: s * 1.7, maxHeight: s * 1.7, child: Transform.rotate( angle: -0.32, child: SizedBox.square( dimension: s * 1.7, child: ClipPath( clipper: _RingHalf(front, compact: compact), child: Stack( fit: StackFit.expand, children: [ Opacity( opacity: strength * (front ? 1 : 0.7), child: Image.asset( 'assets/fx/planet-rings.png', fit: BoxFit.contain, cacheWidth: lowEffects ? 512 : 1024, ), ), CustomPaint( painter: _RingDust( t: t, color: color, lowEffects: lowEffects, ), ), ], ), ), ), ), ), ); }, ); } class _RingHalf extends CustomClipper { const _RingHalf(this.front, {this.compact = false}); final bool compact; final bool front; @override Path getClip(Size size) { var half = Path() ..addRect( Rect.fromLTWH( 0, front ? size.height / 2 : 0, size.width, size.height / 2, ), ); if (compact) { half = Path.combine( PathOperation.intersect, half, Path()..addOval( Rect.fromCenter( center: size.center(Offset.zero), width: size.width * 0.80, height: size.height * 0.32, ), ), ); } if (front) return half; // 본체 에셋의 가장자리 알파에도 뒤 고리가 비치지 않도록 구면 안쪽을 차폐. final body = Path() ..addOval( Rect.fromCircle( center: size.center(Offset.zero), radius: size.width / 1.7 * 0.44, ), ); return Path.combine(PathOperation.difference, half, body); } @override bool shouldReclip(_RingHalf old) => old.front != front || old.compact != compact; } /// 정적 질감 위를 실제로 공전하는 빛가루. 저사양은 수만 줄인다. class _RingDust extends CustomPainter { const _RingDust({ required this.t, required this.color, required this.lowEffects, }); final double t; final Color color; final bool lowEffects; @override void paint(Canvas canvas, Size size) { final random = math.Random(421); final c = size.center(Offset.zero); for (var i = 0; i < (lowEffects ? 6 : 18); i++) { final radius = 0.34 + random.nextDouble() * 0.10; final a = random.nextDouble() * math.pi * 2 + t * math.pi * 2; final p = c + Offset( math.cos(a) * size.width * radius, math.sin(a) * size.height * radius * 0.52, ); final r = size.width * (0.0008 + random.nextDouble() * 0.002); canvas.drawCircle( p, r, Paint() ..color = Color.lerp( color, Colors.white, random.nextDouble(), )!.withValues(alpha: 0.2 + 0.2 * math.sin(a).abs()), ); } } @override bool shouldRepaint(_RingDust old) => old.t != t || old.color != color || old.lowEffects != lowEffects; } /// 행성 둘레의 반짝이. 자리는 고정 시드, 밝기는 자리별 위상으로 흔든다. class _ParticlePainter extends CustomPainter { const _ParticlePainter({ required this.t, required this.color, required this.count, this.steady = false, required this.lowEffects, }); final int count; final bool steady; final bool lowEffects; final double t; final Color color; @override void paint(Canvas canvas, Size size) { final rnd = math.Random(2026); final c = size.center(Offset.zero); for (var i = 0; i < count; i++) { final ang = rnd.nextDouble() * 2 * math.pi; final dist = size.width * (0.38 + rnd.nextDouble() * 0.28); final phase = rnd.nextDouble(); final blink = steady ? 0.55 : (math.sin((t + phase) * 2 * math.pi) + 1) / 2; // 0~1 final p = Offset( c.dx + dist * math.cos(ang), c.dy + dist * math.sin(ang) * 0.8, ); final r = size.width * (0.003 + 0.006 * blink); if (!lowEffects) canvas.drawCircle( p, r * 2.5, Paint() ..color = color.withValues(alpha: 0.35 * blink) ..maskFilter = lowEffects ? null : const MaskFilter.blur(BlurStyle.normal, 4), ); canvas.drawCircle( p, r, Paint() ..color = AppColors.onCosmic.withValues(alpha: 0.4 + 0.6 * blink), ); // 몇 개에는 가는 십자 광선. if (!steady && i % 5 == 0) { final ray = Paint() ..color = AppColors.onCosmic.withValues(alpha: 0.6 * blink) ..strokeWidth = 0.8; canvas.drawLine(p - Offset(r * 3, 0), p + Offset(r * 3, 0), ray); canvas.drawLine(p - Offset(0, r * 3), p + Offset(0, r * 3), ray); } } } @override bool shouldRepaint(_ParticlePainter old) => old.t != t || old.color != color || old.count != count || old.steady != steady || old.lowEffects != lowEffects; } /// Stage 3 공동 에너지 궤도, Stage 4+ 지속 위성. class _OrbitPainter extends CustomPainter { const _OrbitPainter({ required this.t, required this.stage, required this.color, required this.lowEffects, }); final double t; final int stage; final Color color; final bool lowEffects; @override void paint(Canvas canvas, Size size) { final c = size.center(Offset.zero); final s = size.shortestSide; final orbit = Rect.fromCenter(center: c, width: s * 1.18, height: s * 0.52); canvas.save(); canvas.translate(c.dx, c.dy); canvas.rotate(-0.35); canvas.translate(-c.dx, -c.dy); if (stage == 3) { // 공유 에너지 세 무리: 표면 구름(Stage 2)과 다른 주변 구조이며 고체 위성은 아니다. final energyOrbit = Rect.fromCenter( center: c, width: s * 1.32, height: s * 1.04, ); for (var group = 0; group < 3; group++) { final a = (t + group / 3) * math.pi * 2 - 0.4; for (var j = 0; j < (lowEffects ? 3 : 6); j++) { final angle = a - j * 0.07; final p = c + Offset( math.cos(angle) * energyOrbit.width / 2, math.sin(angle) * energyOrbit.height / 2, ); final radius = s * (0.024 - j * 0.0025); canvas.drawCircle( p, radius * 2.3, Paint() ..shader = RadialGradient( colors: [ color.withValues(alpha: 0.65 - j * 0.07), color.withValues(alpha: 0), ], ).createShader(Rect.fromCircle(center: p, radius: radius * 2.3)), ); if (j == 0) canvas.drawCircle( p, radius * 0.48, Paint() ..color = Color.lerp( color, Colors.white, 0.7, )!.withValues(alpha: 0.9 - j * 0.1), ); } } } else { final a = t * math.pi * 2; final p = c + Offset(math.cos(a) * orbit.width / 2, math.sin(a) * orbit.height / 2); final r = s * 0.065; canvas.drawCircle( p, r, Paint() ..shader = RadialGradient( colors: [Colors.white, color, const Color(0xFF251744)], stops: const [0, 0.55, 1], center: const Alignment(-0.4, -0.4), ).createShader(Rect.fromCircle(center: p, radius: r)), ); } canvas.restore(); } @override bool shouldRepaint(_OrbitPainter old) => old.t != t || old.stage != stage || old.color != color || old.lowEffects != lowEffects; } /// Stage 6: 폭죽 대신 구면 주위에 머무는 넓고 일정한 대기광. class _SustainedAura extends CustomPainter { const _SustainedAura({required this.color, required this.lowEffects}); final Color color; final bool lowEffects; @override void paint(Canvas canvas, Size size) { final c = size.center(Offset.zero); final r = size.shortestSide * 0.82; canvas.drawCircle( c, r, Paint() ..shader = RadialGradient( colors: [ Colors.transparent, Colors.transparent, const Color(0xFFC1B9FF).withValues(alpha: lowEffects ? 0.19 : 0.3), color.withValues(alpha: lowEffects ? 0.1 : 0.17), Colors.transparent, ], stops: const [0, 0.50, 0.62, 0.76, 1], ).createShader(Rect.fromCircle(center: c, radius: r)), ); } @override bool shouldRepaint(_SustainedAura old) => old.color != color || old.lowEffects != lowEffects; } /// 이클립스 진행 중 광량. 사전 경고의 Glow 감소 · 실패 감소가 같은 값을 쓴다(해석 확인 Q-A28). const eclipseLight = 0.5; /// 사전 경고 그림자의 출발 거리(행성 한 변 대비). 원문에 수치가 없는 임시값(해석 확인 항목). const eclipseShadowTravel = 0.9; /// 사전 경고 진행 0~1 — [warnAt] 에 0, 발생 시각 [startAt] 에 1. 창이 없거나 경고 전이면 null. /// 발생 시각이 지나도 서버가 회차를 열기 전(스케줄러 지연 수 초)에는 1 로 둔다 — 그림자가 빠졌다 다시 어두워지지 않게. double? eclipseApproach(DateTime? warnAt, DateTime? startAt, DateTime now) { if (warnAt == null || startAt == null || !startAt.isAfter(warnAt)) return null; if (now.isBefore(warnAt)) return null; return (now.difference(warnAt).inMilliseconds / startAt.difference(warnAt).inMilliseconds) .clamp(0.0, 1.0); } /// 이클립스 성공 Orbit Burst 길이. 원문은 「짧은」만 적어 Level Up 경량 연출(850ms)과 같게 둔다(해석 확인 항목). const orbitBurstDuration = Duration(milliseconds: 850); class _OrbitBurstPainter extends CustomPainter { const _OrbitBurstPainter(this.progress, this.color, this.lowEffects); final double progress; final Color color; final bool lowEffects; @override void paint(Canvas canvas, Size size) { if (progress <= 0 || progress >= 1) return; paintOrbitBurst(canvas, size, progress, color, lowEffects); } @override bool shouldRepaint(_OrbitBurstPainter old) => old.progress != progress || old.color != color || old.lowEffects != lowEffects; }