UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출
This commit is contained in:
@@ -4,6 +4,8 @@ class PlanetVisualState {
|
||||
this.stage,
|
||||
this.level,
|
||||
this.isEclipsed = false,
|
||||
this.eclipseFailed = false,
|
||||
this.eclipseSuccessId,
|
||||
this.isHibernating = false,
|
||||
this.isHeroTime = false,
|
||||
this.heroEventIds,
|
||||
@@ -42,6 +44,13 @@ class PlanetVisualState {
|
||||
final String? planetType;
|
||||
final int? level;
|
||||
final bool isEclipsed;
|
||||
|
||||
/// 최근 이클립스 회차가 서버 판정 실패로 끝났는가 — v1.5 §5 실패 「밝기·Glow·Particle 감소」.
|
||||
/// 다음 회차가 열리면(최신 회차가 바뀌면) 풀린다.
|
||||
final bool eclipseFailed;
|
||||
|
||||
/// 서버 판정 성공 회차 ID — 바뀌는 순간 Orbit Burst 한 번(v1.5 §5 성공).
|
||||
final String? eclipseSuccessId;
|
||||
final bool isHibernating;
|
||||
final bool isHeroTime;
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ class Repositories {
|
||||
Stream<PlanetVisualState> _openPlanet(String fandomId) => Stream.multi((out) {
|
||||
PlanetVisualState? planet;
|
||||
var isEclipsed = false;
|
||||
var eclipseFailed = false;
|
||||
String? eclipseSuccessId;
|
||||
var isHeroTime = false;
|
||||
Set<String>? heroEventIds;
|
||||
Timer? heroExpiry;
|
||||
@@ -174,6 +176,8 @@ class Repositories {
|
||||
level: current.level,
|
||||
evolutionImminent: current.evolutionImminent,
|
||||
isEclipsed: isEclipsed,
|
||||
eclipseFailed: eclipseFailed,
|
||||
eclipseSuccessId: eclipseSuccessId,
|
||||
isHeroTime: isHeroTime,
|
||||
heroEventIds: heroEventIds,
|
||||
heroEndsAt: heroEndsAt,
|
||||
@@ -191,9 +195,9 @@ class Repositories {
|
||||
planet = PlanetVisualState.fromDocument(s.data() ?? const {});
|
||||
emit();
|
||||
}, onError: out.addError);
|
||||
final danger = eclipse.watch(fandomId: fandomId).listen((state) {
|
||||
isEclipsed =
|
||||
state != null && !state.defended && !state.isExpired(DateTime.now());
|
||||
// 판정 결과까지 받는다 — 활성 회차만 보면 마감 순간 null 이 되어 성공 · 실패를 가를 수 없다.
|
||||
final danger = eclipse.watch(fandomId: fandomId, includeResults: true).listen((state) {
|
||||
(isEclipsed, eclipseFailed, eclipseSuccessId) = eclipseLook(state, DateTime.now());
|
||||
emit();
|
||||
}, onError: out.addError);
|
||||
// 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다.
|
||||
@@ -264,3 +268,15 @@ class Repositories {
|
||||
// 눌러도 같은 상세가 열린다.
|
||||
: SamplePlanetRepository(fandomId: fandomId);
|
||||
}
|
||||
|
||||
/// 최근 이클립스 회차 → 행성 표현 (진행 중 감소 · 실패 감소 · 성공 회차 ID).
|
||||
/// 판정 대기(마감 후 결과 전)는 셋 다 아니다 — 오류를 실패처럼 보이지 않는다(v1.5 §5 집계 오류).
|
||||
(bool, bool, String?) eclipseLook(EclipseState? state, DateTime now) {
|
||||
if (state == null) return (false, false, null);
|
||||
final won = state.defended || state.result == EclipseResult.success;
|
||||
return (
|
||||
state.result == null && !won && !state.isExpired(now),
|
||||
state.result == EclipseResult.failure,
|
||||
won ? state.eclipseId : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,4 +292,31 @@ class PushNotice {
|
||||
|
||||
/// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다.
|
||||
bool get hasText => title.isNotEmpty || body.isNotEmpty;
|
||||
|
||||
/// 원장 C-78 우선순위. 서버가 지금 싣는 `kind` 만 나눈다 — 모르는 종류는 단순 보상 유도형과 같이
|
||||
/// 가장 뒤([PushTier.other])라 상위 이벤트를 가리지 않는다.
|
||||
PushTier get tier => switch (kind) {
|
||||
'eclipse' => PushTier.crisis,
|
||||
'timeattack' => PushTier.collective,
|
||||
'evolution' => PushTier.achievement,
|
||||
_ => PushTier.other,
|
||||
};
|
||||
|
||||
bool sameAs(PushNotice other) =>
|
||||
kind == other.kind &&
|
||||
title == other.title &&
|
||||
body == other.body &&
|
||||
fandomId == other.fandomId;
|
||||
}
|
||||
|
||||
/// 원장 C-78 「Push 우선순위」 — ① 위기 ② 공동행동 ③ 성취 ④ 결과, 그 밖(단순 보상 유도형 포함)은 맨 뒤.
|
||||
enum PushTier { crisis, collective, achievement, result, other }
|
||||
|
||||
/// 앱 사용 중 수신한 알림을 띄울지 — UX·FX v1.5 §1.1 「Push 와 인앱 티커는 동일 이벤트가 과도하게
|
||||
/// 중첩되지 않도록 노출 빈도를 제어하고 핵심 이벤트는 우선순위를 높게」 · 9장 Eclipse 「앱 사용 중 동일
|
||||
/// 내용의 과도한 중복 Push」 금지.
|
||||
///
|
||||
/// [showing] 은 지금 떠 있는 알림. 같은 알림은 다시 띄우지 않고, 더 낮은 순위가 높은 순위를 덮지 않는다.
|
||||
bool shouldShowInApp(PushNotice next, PushNotice? showing) =>
|
||||
showing == null ||
|
||||
(!next.sameAs(showing) && next.tier.index <= showing.tier.index);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// UX·FX 정렬 기준 v1.5 §1 공통 강도 5단계 — 기능마다 햅틱을 따로 고르지 않게 한 곳에 둔다.
|
||||
///
|
||||
/// | 강도 | 대표 상황(원문) | 햅틱 원칙(원문) |
|
||||
/// |---|---|---|
|
||||
/// | Micro | 출석 · 좋아요 등 경량 행동 | Light 1회 또는 생략 |
|
||||
/// | Small | 일반 ZAP · 일반 Quest | Light |
|
||||
/// | Medium | Music/Location Quest 완료 · Level Up | Medium |
|
||||
/// | Large | Campaign 주요 기여 · Eclipse 공동 방어 | Medium~Strong, 반복 억제 |
|
||||
/// | Legendary | Planet Evolution · Campaign 100% · HERO GIFT | Strong 1회 + 필요 시 짧은 패턴 |
|
||||
///
|
||||
/// Large 는 원문이 「Medium~Strong」 범위라 Medium 과 구분되도록 Strong 을 쓴다(해석 확인 항목).
|
||||
/// Legendary 의 「짧은 패턴」은 원문에 길이 · 횟수가 없어 Strong 1회만 낸다(해석 확인 항목).
|
||||
enum FxIntensity { micro, small, medium, large, legendary }
|
||||
|
||||
/// 강도에 맞는 햅틱 한 번.
|
||||
Future<void> fxHaptic(FxIntensity level) => switch (level) {
|
||||
FxIntensity.micro || FxIntensity.small => HapticFeedback.lightImpact(),
|
||||
FxIntensity.medium => HapticFeedback.mediumImpact(),
|
||||
FxIntensity.large || FxIntensity.legendary => HapticFeedback.heavyImpact(),
|
||||
};
|
||||
@@ -45,6 +45,8 @@ class LivePlanet25D extends StatelessWidget {
|
||||
level: state?.level,
|
||||
evolutionImminent: state?.evolutionImminent ?? false,
|
||||
isEclipsed: state?.isEclipsed ?? false,
|
||||
eclipseFailed: state?.eclipseFailed ?? false,
|
||||
eclipseSuccessId: state?.eclipseSuccessId,
|
||||
isHibernating: state?.isHibernating ?? false,
|
||||
isHeroTime: state?.isHeroTime ?? false,
|
||||
heroEventIds: state?.heroEventIds,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -134,6 +135,8 @@ class Planet25D extends StatefulWidget {
|
||||
this.level,
|
||||
this.evolutionImminent = false,
|
||||
this.isEclipsed = false,
|
||||
this.eclipseFailed = false,
|
||||
this.eclipseSuccessId,
|
||||
this.isHibernating = false,
|
||||
this.isHeroTime = false,
|
||||
this.heroEventIds,
|
||||
@@ -161,6 +164,12 @@ class Planet25D extends StatefulWidget {
|
||||
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;
|
||||
final bool isHibernating;
|
||||
final bool isHeroTime;
|
||||
final Set<String>? heroEventIds;
|
||||
@@ -180,7 +189,7 @@ class Planet25D extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _Planet25DState extends State<Planet25D>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with TickerProviderStateMixin {
|
||||
int get _quality => widget.lowEffects ? 2 : PlanetEffectsQuality.of(context);
|
||||
bool get _lowEffects => _quality >= 2;
|
||||
bool get _celebrating =>
|
||||
@@ -198,6 +207,9 @@ class _Planet25DState extends State<Planet25D>
|
||||
? 0.5
|
||||
: _celebrating
|
||||
? 1.3
|
||||
: widget.eclipseFailed
|
||||
// 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목).
|
||||
? 0.5
|
||||
: 1;
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
@@ -217,6 +229,7 @@ class _Planet25DState extends State<Planet25D>
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_fx = PlanetFxScope.of(context);
|
||||
_syncMotion();
|
||||
}
|
||||
|
||||
@@ -230,10 +243,32 @@ class _Planet25DState extends State<Planet25D>
|
||||
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();
|
||||
}
|
||||
@@ -252,10 +287,39 @@ class _Planet25DState extends State<Planet25D>
|
||||
heroEnabled: !widget.isEclipsed && !widget.isHibernating,
|
||||
animate: widget.animate,
|
||||
lowEffects: _lowEffects,
|
||||
builder: (stage) => Opacity(
|
||||
key: const ValueKey('planet-surface-light'),
|
||||
opacity: light.clamp(0.0, 1.0),
|
||||
child: _planet(context, stage, light),
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -818,3 +882,25 @@ class _SustainedAura extends CustomPainter {
|
||||
bool shouldRepaint(_SustainedAura old) =>
|
||||
old.color != color || old.lowEffects != lowEffects;
|
||||
}
|
||||
|
||||
/// 이클립스 성공 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;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
import 'fx_intensity.dart';
|
||||
|
||||
/// v1.5 §2.1/6. 진화·HERO 발동은 하나의 타임라인을 쓰며 첫 조회는 기준점이다.
|
||||
class PlanetEvolution extends StatefulWidget {
|
||||
@@ -206,9 +206,9 @@ class _PlanetEvolutionState extends State<PlanetEvolution>
|
||||
_fx.forward(from: 0);
|
||||
if (ending) return;
|
||||
if (evolution || hero) {
|
||||
HapticFeedback.heavyImpact();
|
||||
fxHaptic(FxIntensity.legendary); // Evolution · HERO GIFT
|
||||
} else {
|
||||
HapticFeedback.mediumImpact();
|
||||
fxHaptic(FxIntensity.medium); // Level Up
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,35 +469,7 @@ class _HeroPainter extends CustomPainter {
|
||||
).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),
|
||||
);
|
||||
}
|
||||
paintOrbitBurst(canvas, size, (p - 0.5) * 2, color, lowEffects);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,3 +479,44 @@ class _HeroPainter extends CustomPainter {
|
||||
old.lowEffects != lowEffects ||
|
||||
old.ending != ending;
|
||||
}
|
||||
|
||||
/// Orbit Burst — 기울어진 궤도 고리가 바깥으로 퍼지고 입자가 흩어진다. [burst] 0 → 1.
|
||||
/// HERO 발동의 Ring Wave 와 이클립스 성공(v1.5 §5 「짧은 Orbit Burst」)이 같이 쓴다.
|
||||
void paintOrbitBurst(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
double burst,
|
||||
Color color,
|
||||
bool lowEffects,
|
||||
) {
|
||||
final c = size.center(Offset.zero);
|
||||
final r = size.shortestSide * 0.46;
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user