1017 lines
36 KiB
Dart
1017 lines
36 KiB
Dart
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;
|
||
}
|