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

1017 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, dynamic>? 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<int>(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,
this.spinBoost = false,
});
/// GIVE 10회 이상 연타 중 — Figma `2033:15818` 「자전속도 1.5 배」. 본체는 자전하지 않으므로(C-185)
/// 구름 · 궤도 · 링 먼지 · 펄스가 도는 주기를 1.5배 빠르게 한다.
final bool spinBoost;
/// 다음 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<String>? heroEventIds;
/// 저사양에서는 상태를 유지하면서 입자·Blur만 축소한다.
final bool lowEffects;
/// 정지 화면(목록 카드 등)에서는 끈다.
final bool animate;
/// 전역 스위치. 위젯 테스트는 `pumpAndSettle` 이 끝나야 하므로 끈다
/// (`test/flutter_test_config.dart`). 앱에서는 항상 켜져 있다.
static bool animationsEnabled = true;
@override
State<Planet25D> createState() => _Planet25DState();
}
class _Planet25DState extends State<Planet25D>
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;
Duration get _cycle =>
Duration(milliseconds: (_period * 1000 / (widget.spinBoost ? 1.5 : 1)).round());
late final AnimationController _c = AnimationController(
vsync: this,
duration: _cycle,
);
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 ||
old.spinBoost != widget.spinBoost) {
_c.duration = _cycle;
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<double>(
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],
),
),
),
),
),
),
),
),
// Stage 6 — Figma `2024:20821` 「자기장(Field) 라인이 행성을 감싸며 외곽으로 방사됨」.
// 본체 뒤에 깔아 구면 바깥으로 뻗는 부분만 보이게 한다.
if (stage == 6)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
key: const ValueKey('planet-field-lines'),
painter: _FieldLinePainter(
t: t,
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: 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<Path> {
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 _FieldLinePainter extends CustomPainter {
const _FieldLinePainter({
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 c = size.center(Offset.zero);
final r = size.shortestSide * 0.42;
final loops = lowEffects ? 2 : 4;
// 방사 위치 0~1 — 한 주기에 네 번 안쪽에서 바깥으로.
final wave = (t * 4) % 1;
for (var i = 0; i < loops; i++) {
final k = (i + 1) / loops;
final w = r * (0.55 + 0.75 * k);
final h = r * (1.05 + 0.25 * k);
final glow = 1 - ((k - wave).abs() * 2.5).clamp(0.0, 1.0);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0 + glow * 1.2
..color = Color.lerp(color, Colors.white, 0.35)!
.withValues(alpha: (0.14 + 0.36 * glow) * (1 - k * 0.4));
for (final side in const [-1.0, 1.0]) {
canvas.drawOval(
Rect.fromCenter(
center: c + Offset(side * w / 2, 0),
width: w,
height: h * 2,
),
paint,
);
}
}
}
@override
bool shouldRepaint(_FieldLinePainter old) =>
old.t != t || 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) =>
paintSurfaceAura(canvas, size, color, lowEffects: lowEffects);
@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;
}