Files
BlockSeasons/docs/superpowers/plans/2026-06-11-commercial-polish.md
T
2026-06-11 20:52:26 +09:00

3098 lines
91 KiB
Markdown
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.
# Phase 3.5 — 상용 품질 폴리시 구현 계획
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Block Seasons를 상용 품질로 끌어올린다 — 글로시 타일 + 시즌 테마 배경, 타격감 주스, 인트로(스플래시/시즌 카드/튜토리얼), 여정 경로 맵, 엔드리스 모드.
**Architecture:** 순수 Dart 레이어(`lib/game/`, `lib/core/`)는 Flutter import 금지(아키텍처 가드 테스트 존재). SeasonTheme은 int ARGB로 색을 저장해 순수성 유지. UI는 Riverpod provider로만 상태를 읽는다. 모든 신규 로직은 TDD(RED→GREEN→커밋).
**Tech Stack:** Flutter 3.35 / Riverpod 2 (plain Notifier) / shared_preferences / 신규 패키지 없음 (햅틱은 `flutter/services.dart``HapticFeedback`).
**Spec:** `docs/superpowers/specs/2026-06-11-commercial-polish-design.md`
**공통 규칙:**
- 모든 명령은 `/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons` 안에서 실행.
- 경로에 공백이 있으므로 cd 시 반드시 따옴표.
- 각 태스크 끝에 커밋. 커밋 후 `git push`는 Task 16에서 일괄 1회면 충분.
- 기존 테스트가 같이 깨지면 그 태스크 안에서 고친다(특히 골든 테스트).
---
### Task 1: SeasonTheme 모델 확장 (순수 Dart, int ARGB)
기존 `SeasonTheme``{tileSet, background}` 문자열 2개뿐. 비주얼 필드를 추가하되 **모든 신규 필드는 옵셔널 + 시즌 1 기본값** — 기존 `pack.json`(theme에 tileSet/background만 있음)이 그대로 파싱돼야 한다.
**Files:**
- Modify: `lib/game/models/season.dart`
- Test: `test/game/models/season_test.dart` (기존 파일에 테스트 추가)
- [ ] **Step 1: 실패하는 테스트 작성**
`test/game/models/season_test.dart`에 기존 `main()` 안에 추가:
```dart
group('SeasonTheme visuals', () {
test('legacy theme json (tileSet/background only) gets defaults', () {
final theme = SeasonTheme.fromJson({
'tileSet': 'spring',
'background': 'background.webp',
});
expect(theme.backgroundGradient, SeasonTheme.defaultGradient);
expect(theme.accentColor, 0xFFFF7EB3);
expect(theme.particleType, 'petals');
expect(theme.tilePalette, isNull);
expect(theme.boardTint, isNull);
});
// (legacy tileSet/background 필드도 그대로 보존되는지 확인)
test('full theme json round-trips', () {
final theme = SeasonTheme(
tileSet: 'summer',
background: 'bg.webp',
backgroundGradient: const [0xFF0A2430, 0xFF10394A, 0xFF1E5A66],
accentColor: 0xFF6FCDF5,
particleType: 'snow',
tilePalette: const [0xFF111111, 0xFF222222],
boardTint: 0xFF041016,
);
final decoded = SeasonTheme.fromJson(theme.toJson());
expect(decoded.backgroundGradient, theme.backgroundGradient);
expect(decoded.accentColor, theme.accentColor);
expect(decoded.particleType, theme.particleType);
expect(decoded.tilePalette, theme.tilePalette);
expect(decoded.boardTint, theme.boardTint);
});
test('fallback constant matches season 1 defaults', () {
expect(SeasonTheme.fallback.backgroundGradient,
const [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E]);
expect(SeasonTheme.fallback.particleType, 'petals');
});
});
```
- [ ] **Step 2: 실패 확인**
```
flutter test test/game/models/season_test.dart
```
Expected: FAIL — `backgroundGradient` 등이 정의되지 않음.
- [ ] **Step 3: 구현**
`lib/game/models/season.dart``SeasonTheme` 클래스를 다음으로 교체:
```dart
/// Visual identity of a season. Colors are int ARGB so this file stays
/// pure Dart (architecture guard forbids Flutter imports here).
class SeasonTheme {
const SeasonTheme({
this.tileSet = 'spring',
this.background = '',
this.backgroundGradient = defaultGradient,
this.accentColor = 0xFFFF7EB3,
this.particleType = 'petals',
this.tilePalette,
this.boardTint,
});
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
tileSet: (json['tileSet'] as String?) ?? 'spring',
background: (json['background'] as String?) ?? '',
backgroundGradient: json['backgroundGradient'] != null
? [for (final c in json['backgroundGradient'] as List) c as int]
: defaultGradient,
accentColor: (json['accentColor'] as int?) ?? 0xFFFF7EB3,
particleType: (json['particleType'] as String?) ?? 'petals',
tilePalette: json['tilePalette'] != null
? [for (final c in json['tilePalette'] as List) c as int]
: null,
boardTint: json['boardTint'] as int?,
);
/// Season 1 "First Bloom": deep navy dusk.
static const defaultGradient = [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E];
static const fallback = SeasonTheme();
final String tileSet;
final String background;
/// Top-to-bottom screen gradient, int ARGB.
final List<int> backgroundGradient;
final int accentColor;
/// petals | snow | leaves | none
final String particleType;
/// Optional 8-color tile override; null = built-in candy palette.
final List<int>? tilePalette;
/// Optional board background override.
final int? boardTint;
Map<String, dynamic> toJson() => {
'tileSet': tileSet,
'background': background,
'backgroundGradient': backgroundGradient,
'accentColor': accentColor,
'particleType': particleType,
if (tilePalette != null) 'tilePalette': tilePalette,
if (boardTint != null) 'boardTint': boardTint,
};
}
```
- [ ] **Step 4: 전체 관련 테스트 통과 확인**
```
flutter test test/game/models/ test/architecture_test.dart test/data/content_repository_test.dart
```
Expected: PASS (기존 pack.json 파싱 포함).
- [ ] **Step 5: 커밋**
```bash
git add lib/game/models/season.dart test/game/models/season_test.dart
git commit -m "feat: extend SeasonTheme with visual identity fields (ARGB ints)"
```
---
### Task 2: 글로시 타일 페인터 + activeThemeProvider
타일을 단색 → 광택(그라데이션+하이라이트+글로우)으로. 공유 함수 `paintGlossyTile` 하나를 board/piece 페인터가 같이 쓴다(DRY).
**Files:**
- Create: `lib/ui/widgets/tile_painter.dart`
- Modify: `lib/ui/theme/palette.dart` (테마 적용 헬퍼)
- Modify: `lib/ui/widgets/board_painter.dart`
- Modify: `lib/ui/widgets/piece_painter.dart`
- Modify: `lib/state/providers.dart` (activeThemeProvider)
- Golden: `test/ui/game_screen_golden_test.dart` 재생성
- [ ] **Step 1: tile_painter.dart 작성**
```dart
import 'package:flutter/material.dart';
Color lighten(Color c, double amount) => Color.lerp(c, Colors.white, amount)!;
Color darken(Color c, double amount) => Color.lerp(c, Colors.black, amount)!;
/// Candy-gloss tile: diagonal gradient body, glass top highlight, optional
/// colored glow (used for gems and clear flashes). Shared by the board,
/// tray, and drag overlay so every tile in the game matches.
void paintGlossyTile(
Canvas canvas,
Rect rect,
Color color, {
double radiusFactor = 0.18,
double glow = 0,
}) {
final radius = Radius.circular(rect.width * radiusFactor);
final rrect = RRect.fromRectAndRadius(rect, radius);
if (glow > 0) {
final glowPaint = Paint()
..color = color.withValues(alpha: 0.55 * glow)
..maskFilter =
MaskFilter.blur(BlurStyle.normal, rect.width * 0.28 * glow);
canvas.drawRRect(rrect.inflate(rect.width * 0.05), glowPaint);
}
final body = Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [lighten(color, 0.28), color, darken(color, 0.22)],
stops: const [0.0, 0.45, 1.0],
).createShader(rect);
canvas.drawRRect(rrect, body);
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.30);
final hl = Rect.fromLTWH(
rect.left + rect.width * 0.10,
rect.top + rect.height * 0.07,
rect.width * 0.80,
rect.height * 0.30,
);
canvas.drawRRect(
RRect.fromRectAndRadius(hl, Radius.circular(rect.width * 0.12)),
highlight,
);
}
```
- [ ] **Step 2: palette.dart에 테마 인스턴스 헬퍼 추가**
`GamePalette` 클래스 아래에 추가 (기존 static 멤버는 그대로 둠 — 기본값으로 계속 쓰임):
```dart
/// Resolved per-season colors for the UI layer. Built from a SeasonTheme;
/// falls back to the GamePalette constants.
class ThemeColors {
ThemeColors(SeasonTheme theme)
: gradient = [for (final c in theme.backgroundGradient) Color(c)],
accent = Color(theme.accentColor),
particleType = theme.particleType,
board = theme.boardTint != null
? Color(theme.boardTint!)
: GamePalette.boardBackground,
tiles = theme.tilePalette != null
? [for (final c in theme.tilePalette!) Color(c)]
: GamePalette.tileColors;
final List<Color> gradient;
final Color accent;
final String particleType;
final Color board;
final List<Color> tiles;
Color tile(int colorId) => tiles[colorId % tiles.length];
}
```
상단에 `import '../../game/models/season.dart';` 추가.
- [ ] **Step 3: providers.dart에 activeThemeProvider 추가**
```dart
/// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) {
final flow = ref.watch(seasonFlowProvider);
return flow?.pack.theme ?? SeasonTheme.fallback;
});
```
(`season.dart`는 이미 import되어 있음.)
- [ ] **Step 4: board_painter.dart 글로시 적용**
`paint()`의 셀 루프 부분을 교체 — filled 셀과 gem을 글로시로:
```dart
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
final rect = geo.cellRect(x, y).deflate(inset);
final cell = grid.cellAt(x, y);
switch (cell.type) {
case CellType.empty:
canvas.drawRRect(
RRect.fromRectAndRadius(rect, radius),
Paint()..color = GamePalette.emptyCell,
);
case CellType.filled:
paintGlossyTile(canvas, rect, GamePalette.tile(cell.colorId));
case CellType.gem:
canvas.drawRRect(
RRect.fromRectAndRadius(rect, radius),
Paint()..color = GamePalette.emptyCell,
);
_paintGem(canvas, rect);
}
}
}
```
`_paintGem`에 글로우 추가 — 첫 줄에:
```dart
final glowPaint = Paint()
..color = GamePalette.gem.withValues(alpha: 0.45)
..maskFilter = MaskFilter.blur(BlurStyle.normal, rect.width * 0.25);
canvas.drawCircle(rect.center, rect.width * 0.32, glowPaint);
```
기존 filled 하이라이트 블록(`} else if (cell.type == CellType.filled) { ... }`)은 삭제 (글로시 함수가 대체). import 추가: `import 'tile_painter.dart';`
- [ ] **Step 5: piece_painter.dart 글로시 적용**
`paintPiece`의 루프를 교체:
```dart
for (final (dx, dy) in piece.offsets) {
final rect = Rect.fromLTWH(
origin.dx + dx * cellSize + inset,
origin.dy + dy * cellSize + inset,
cellSize - inset * 2,
cellSize - inset * 2,
);
if (overrideColor != null) {
canvas.drawRRect(
RRect.fromRectAndRadius(rect, radius),
Paint()..color = overrideColor,
);
} else {
paintGlossyTile(canvas, rect,
GamePalette.tile(piece.colorId));
}
}
```
기존 highlight 블록 삭제, `import 'tile_painter.dart';` 추가. (고스트는 overrideColor 경로라 기존처럼 평면 반투명.)
- [ ] **Step 6: analyze + 골든 재생성 + 전체 테스트**
```
flutter analyze
flutter test --update-goldens test/ui/game_screen_golden_test.dart
flutter test
```
Expected: analyze 0 issues, 전체 PASS.
- [ ] **Step 7: 커밋**
```bash
git add lib/ui lib/state/providers.dart test/ui/goldens
git commit -m "feat: glossy tile rendering and per-season theme colors"
```
---
### Task 3: SeasonBackground 위젯 + 게임 화면 적용
그라데이션 + 떠다니는 꽃잎을 그리는 공용 배경. **루핑 애니메이션은 테스트의 pumpAndSettle을 영원히 돌게 하므로** 전역 플래그로 차단하고 `test/flutter_test_config.dart`에서 켠다.
**Files:**
- Create: `lib/ui/widgets/season_background.dart`
- Create: `test/flutter_test_config.dart`
- Modify: `lib/ui/screens/game_screen.dart`
- Test: `test/ui/season_background_test.dart`
- [ ] **Step 1: 실패하는 위젯 테스트 작성**
`test/ui/season_background_test.dart`:
```dart
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/ui/widgets/season_background.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('renders and settles with looping animations disabled',
(tester) async {
await tester.pumpWidget(const MaterialApp(
home: SeasonBackground(theme: SeasonTheme.fallback),
));
await tester.pumpAndSettle();
expect(find.byType(SeasonBackground), findsOneWidget);
});
testWidgets('particleType none still renders', (tester) async {
await tester.pumpWidget(const MaterialApp(
home: SeasonBackground(
theme: SeasonTheme(particleType: 'none'),
),
));
await tester.pumpAndSettle();
expect(find.byType(CustomPaint), findsWidgets);
});
}
```
`test/flutter_test_config.dart`:
```dart
import 'dart:async';
import 'package:block_seasons/ui/widgets/season_background.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
// Looping ambience animations never settle under pumpAndSettle.
debugDisableLoopingAnimations = true;
await testMain();
}
```
- [ ] **Step 2: 실패 확인**
```
flutter test test/ui/season_background_test.dart
```
Expected: FAIL — 파일/심볼 없음.
- [ ] **Step 3: season_background.dart 구현**
```dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../game/models/season.dart';
import '../theme/palette.dart';
/// Set true in tests (flutter_test_config.dart): looping ambience would make
/// pumpAndSettle spin forever.
bool debugDisableLoopingAnimations = false;
/// Full-screen season ambience: vertical gradient plus drifting particles
/// (petals for season 1). Pure procedural — no image assets required; an AI
/// illustration layer can be added on top later without touching callers.
class SeasonBackground extends StatefulWidget {
const SeasonBackground({super.key, required this.theme});
final SeasonTheme theme;
@override
State<SeasonBackground> createState() => _SeasonBackgroundState();
}
class _SeasonBackgroundState extends State<SeasonBackground>
with SingleTickerProviderStateMixin {
late final AnimationController _drift = AnimationController(
vsync: this,
duration: const Duration(seconds: 18),
);
@override
void initState() {
super.initState();
if (!debugDisableLoopingAnimations) _drift.repeat();
}
@override
void dispose() {
_drift.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = ThemeColors(widget.theme);
return RepaintBoundary(
child: AnimatedBuilder(
animation: _drift,
builder: (context, _) => CustomPaint(
size: Size.infinite,
painter: _AmbiencePainter(colors: colors, t: _drift.value),
),
),
);
}
}
class _AmbiencePainter extends CustomPainter {
const _AmbiencePainter({required this.colors, required this.t});
final ThemeColors colors;
final double t;
static const _particles = 9;
// Deterministic pseudo-random in [0, 1) from an index.
static double _hash(int i, double salt) {
final v = math.sin(i * 12.9898 + salt) * 43758.5453;
return v - v.floorToDouble();
}
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: colors.gradient,
stops: const [0.0, 0.55, 1.0],
).createShader(rect),
);
if (colors.particleType == 'none') return;
for (var i = 0; i < _particles; i++) {
final speed = 0.5 + _hash(i, 1) * 0.6;
final phase = _hash(i, 2);
final fall = (t * speed + phase) % 1.15 - 0.075;
final x = (_hash(i, 3) +
0.05 * math.sin(t * 2 * math.pi + i * 1.7)) *
size.width;
final y = fall * size.height;
final scale = 7 + _hash(i, 4) * 9;
final angle = t * 2 * math.pi * (0.4 + _hash(i, 5)) + i;
_paintParticle(canvas, Offset(x, y), scale, angle);
}
}
void _paintParticle(Canvas canvas, Offset c, double s, double angle) {
canvas.save();
canvas.translate(c.dx, c.dy);
canvas.rotate(angle);
final paint = Paint();
switch (colors.particleType) {
case 'snow':
paint.color = Colors.white.withValues(alpha: 0.35);
canvas.drawCircle(Offset.zero, s * 0.4, paint);
case 'leaves':
paint.color = const Color(0xFFE8945A).withValues(alpha: 0.35);
canvas.drawOval(
Rect.fromCenter(center: Offset.zero, width: s, height: s * 0.55),
paint);
default: // petals
paint.color = colors.accent.withValues(alpha: 0.30);
canvas.drawOval(
Rect.fromCenter(
center: Offset(s * 0.18, 0), width: s, height: s * 0.62),
paint);
canvas.drawOval(
Rect.fromCenter(
center: Offset(-s * 0.18, 0), width: s, height: s * 0.62),
paint);
}
canvas.restore();
}
@override
bool shouldRepaint(_AmbiencePainter old) =>
old.t != t || old.colors != colors;
}
```
- [ ] **Step 4: GameScreen에 배경 적용**
`lib/ui/screens/game_screen.dart``build()`에서 `Scaffold(body: SafeArea(child: Stack(...)))` 구조를 다음으로 변경:
```dart
final theme = ref.watch(activeThemeProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
fit: StackFit.expand,
children: [
SeasonBackground(theme: theme),
SafeArea(
child: Stack(
key: _stackKey,
children: [
// ... 기존 children 그대로 ...
],
),
),
],
),
);
```
import 추가:
```dart
import '../../game/models/season.dart';
import '../widgets/season_background.dart';
```
- [ ] **Step 5: 테스트 + 골든 재생성**
```
flutter analyze
flutter test --update-goldens test/ui/game_screen_golden_test.dart
flutter test
```
Expected: 전체 PASS.
- [ ] **Step 6: 커밋**
```bash
git add lib/ui test/ui test/flutter_test_config.dart
git commit -m "feat: procedural season background with drifting petals"
```
---
### Task 4: SaveRepository 확장 — tutorialDone / endlessBest
세이브 블롭에 additive 필드 2개. saveVersion은 1 유지(하위호환 추가라 범프 불필요).
**Files:**
- Modify: `lib/data/save_repository.dart`
- Test: `test/data/save_repository_test.dart` (추가)
- [ ] **Step 1: 실패하는 테스트 작성**
`test/data/save_repository_test.dart``main()`에 추가:
```dart
group('tutorial flag and endless best', () {
test('defaults: tutorial not done, endless best 0', () async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
expect(repo.tutorialDone, isFalse);
expect(repo.endlessBest, 0);
});
test('markTutorialDone persists across reload', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await SaveRepository(prefs).markTutorialDone();
expect(SaveRepository(prefs).tutorialDone, isTrue);
});
test('recordEndlessScore keeps the max', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final repo = SaveRepository(prefs);
await repo.recordEndlessScore(500);
await repo.recordEndlessScore(300);
expect(repo.endlessBest, 500);
expect(SaveRepository(prefs).endlessBest, 500);
});
test('legacy blob without new keys still loads', () async {
SharedPreferences.setMockInitialValues({
'save_v1':
'{"saveVersion":1,"progress":{},"streak":{"current":0,"best":0,"lastYmd":null}}',
});
final repo = SaveRepository(await SharedPreferences.getInstance());
expect(repo.tutorialDone, isFalse);
expect(repo.endlessBest, 0);
});
});
```
- [ ] **Step 2: 실패 확인**
```
flutter test test/data/save_repository_test.dart
```
Expected: FAIL — getter 없음.
- [ ] **Step 3: 구현**
`save_repository.dart` 필드 추가:
```dart
bool _tutorialDone = false;
int _endlessBest = 0;
bool get tutorialDone => _tutorialDone;
int get endlessBest => _endlessBest;
Future<void> markTutorialDone() {
_tutorialDone = true;
return _flush();
}
Future<void> recordEndlessScore(int score) {
if (score > _endlessBest) _endlessBest = score;
return _flush();
}
```
생성자의 json 파싱부 끝에 추가:
```dart
_tutorialDone =
(json['flags'] as Map<String, dynamic>?)?['tutorialDone'] as bool? ??
false;
_endlessBest =
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
```
`_flush()`의 jsonEncode 맵에 추가:
```dart
'flags': {'tutorialDone': _tutorialDone},
'endless': {'best': _endlessBest},
```
- [ ] **Step 4: 통과 확인 + 커밋**
```
flutter test test/data/
```
Expected: PASS.
```bash
git add lib/data/save_repository.dart test/data/save_repository_test.dart
git commit -m "feat: persist tutorial completion and endless best score"
```
---
### Task 5: EffectsOverlay 주스 키트 + 햅틱 + 화면 흔들림
스파크/점수팝업/콤보배너/컨페티/배치-바운스를 한 오버레이로. 콤보 텍스트는 BoardWidget에서 제거(중복 방지).
**Files:**
- Create: `lib/ui/widgets/effects_overlay.dart`
- Modify: `lib/ui/screens/game_screen.dart`
- Modify: `lib/ui/widgets/board_widget.dart` (콤보 텍스트 제거)
- [ ] **Step 1: effects_overlay.dart 구현**
```dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../../game/engine/game_engine.dart';
import '../../game/engine/game_event.dart';
import '../../game/models/grid.dart';
import '../theme/palette.dart';
import 'board_geometry.dart';
import 'tile_painter.dart';
enum _FxType { spark, popup, combo, confetti, settle }
class _Fx {
_Fx(this.type, this.start, {this.pos = Offset.zero, this.data});
final _FxType type;
final Duration start;
final Offset pos;
final Object? data;
static const durations = {
_FxType.spark: Duration(milliseconds: 600),
_FxType.popup: Duration(milliseconds: 900),
_FxType.combo: Duration(milliseconds: 1000),
_FxType.confetti: Duration(milliseconds: 1800),
_FxType.settle: Duration(milliseconds: 140),
};
double progress(Duration now) {
final d = durations[type]!;
final p = (now - start).inMicroseconds / d.inMicroseconds;
return p.clamp(0.0, 1.0);
}
bool done(Duration now) => progress(now) >= 1;
}
/// Transient game-feel effects above the board: clear sparks, rising score
/// popups, combo banners, win confetti, placed-piece settle. The game screen
/// reports events; effects expire on their own and the ticker stops when the
/// list drains, so widget tests settle normally.
class EffectsOverlay extends StatefulWidget {
const EffectsOverlay({super.key});
@override
State<EffectsOverlay> createState() => EffectsOverlayState();
}
class EffectsOverlayState extends State<EffectsOverlay>
with SingleTickerProviderStateMixin {
late final Ticker _ticker = createTicker(_tick);
final List<_Fx> _fx = [];
Duration _now = Duration.zero;
void _tick(Duration elapsed) {
setState(() {
_now = elapsed;
_fx.removeWhere((e) => e.done(elapsed));
if (_fx.isEmpty) _ticker.stop();
});
}
void _add(_Fx fx) {
_fx.add(fx);
if (!_ticker.isActive) _ticker.start();
}
/// [boardRect] is the board's rect in this overlay's coordinates.
void onPlacement(PlacementResult placement, {required Rect boardRect}) {
final geo = BoardGeometry(boardSize: boardRect.width);
final origin = boardRect.topLeft;
for (final event in placement.events) {
if (event is PiecePlaced) {
_add(_Fx(_FxType.settle, _now,
pos: origin +
Offset(event.x * geo.cellSize, event.y * geo.cellSize),
data: (event.piece, geo.cellSize)));
}
}
final cleared = <Offset>[];
for (final y in placement.clearedRows) {
for (var x = 0; x < GridState.size; x++) {
cleared.add(origin + geo.cellRect(x, y).center);
}
}
for (final x in placement.clearedCols) {
for (var y = 0; y < GridState.size; y++) {
cleared.add(origin + geo.cellRect(x, y).center);
}
}
for (final c in cleared) {
_add(_Fx(_FxType.spark, _now, pos: c, data: geo.cellSize));
}
if (placement.pointsGained > 0 && placement.linesCleared > 0) {
final at = cleared.isEmpty
? boardRect.center
: cleared[cleared.length ~/ 2];
_add(_Fx(_FxType.popup, _now,
pos: at, data: '+${placement.pointsGained}'));
}
if (placement.comboStreak >= 2) {
_add(_Fx(_FxType.combo, _now,
pos: boardRect.center, data: placement.comboStreak));
}
}
void onWin(Size screenSize) {
for (var i = 0; i < 36; i++) {
_add(_Fx(_FxType.confetti, _now,
pos: Offset(screenSize.width * _hash(i, 1), -12),
data: i));
}
}
static double _hash(int i, double salt) {
final v = math.sin(i * 12.9898 + salt) * 43758.5453;
return v - v.floorToDouble();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: CustomPaint(
size: Size.infinite,
painter: _FxPainter(List.of(_fx), _now),
),
);
}
}
class _FxPainter extends CustomPainter {
const _FxPainter(this.fx, this.now);
final List<_Fx> fx;
final Duration now;
@override
void paint(Canvas canvas, Size size) {
for (final e in fx) {
final t = e.progress(now);
switch (e.type) {
case _FxType.spark:
_spark(canvas, e, t);
case _FxType.popup:
_popup(canvas, e, t);
case _FxType.combo:
_combo(canvas, e, t, size);
case _FxType.confetti:
_confetti(canvas, e, t, size);
case _FxType.settle:
_settle(canvas, e, t);
}
}
}
void _spark(Canvas canvas, _Fx e, double t) {
final cell = e.data as double;
for (var i = 0; i < 6; i++) {
final angle = i * math.pi / 3 + EffectsOverlayState._hash(i, 7) * 0.8;
final dist = cell * (0.3 + 1.2 * Curves.easeOut.transform(t));
final pos = e.pos + Offset(math.cos(angle), math.sin(angle)) * dist;
canvas.drawCircle(
pos,
cell * 0.09 * (1 - t),
Paint()
..color = Colors.white.withValues(alpha: (1 - t) * 0.9),
);
}
}
void _popup(Canvas canvas, _Fx e, double t) {
final rise = 44 * Curves.easeOut.transform(t);
_text(canvas, e.data as String, e.pos - Offset(0, rise),
fontSize: 22,
color: Colors.white.withValues(alpha: 1 - t * t),
outline: true);
}
void _combo(Canvas canvas, _Fx e, double t, Size size) {
final streak = e.data as int;
final color = streak >= 6
? const Color(0xFFB980FF)
: streak >= 4
? const Color(0xFFFF8A4D)
: const Color(0xFFFFD166);
final scale = t < 0.25
? Curves.easeOutBack.transform(t / 0.25)
: 1.0;
final alpha = t > 0.7 ? (1 - t) / 0.3 : 1.0;
canvas.save();
canvas.translate(e.pos.dx, e.pos.dy - 30);
canvas.scale(scale);
_text(canvas, 'COMBO ×$streak', Offset.zero,
fontSize: streak >= 4 ? 40 : 34,
color: color.withValues(alpha: alpha),
outline: true);
canvas.restore();
}
void _confetti(Canvas canvas, _Fx e, double t, Size size) {
final i = e.data as int;
final colors = GamePalette.tileColors;
final x = e.pos.dx +
28 * math.sin(t * 4 * math.pi + i);
final y = t * (size.height + 40);
canvas.save();
canvas.translate(x, y);
canvas.rotate(t * 6 * math.pi * (EffectsOverlayState._hash(i, 3) - 0.5));
canvas.drawRect(
Rect.fromCenter(center: Offset.zero, width: 9, height: 5),
Paint()
..color =
colors[i % colors.length].withValues(alpha: 1 - t * 0.6),
);
canvas.restore();
}
void _settle(Canvas canvas, _Fx e, double t) {
final (piece, cellSize) = e.data as (dynamic, double);
final scale = 1.08 - 0.08 * Curves.easeOut.transform(t);
final alpha = 0.35 * (1 - t);
for (final (dx, dy) in piece.offsets) {
final rect = Rect.fromLTWH(
e.pos.dx + dx * cellSize,
e.pos.dy + dy * cellSize,
cellSize,
cellSize,
);
final scaled = Rect.fromCenter(
center: rect.center,
width: rect.width * scale,
height: rect.height * scale,
).deflate(cellSize * 0.05);
canvas.drawRRect(
RRect.fromRectAndRadius(scaled, Radius.circular(cellSize * 0.18)),
Paint()..color = Colors.white.withValues(alpha: alpha),
);
}
}
void _text(Canvas canvas, String s, Offset center,
{required double fontSize, required Color color, bool outline = false}) {
final painter = TextPainter(
text: TextSpan(
text: s,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w900,
color: color,
shadows: outline
? const [Shadow(blurRadius: 14, color: Colors.black87)]
: null,
),
),
textDirection: TextDirection.ltr,
)..layout();
painter.paint(
canvas, center - Offset(painter.width / 2, painter.height / 2));
}
@override
bool shouldRepaint(_FxPainter old) => true;
}
```
주의: `_settle``(dynamic, double)` 레코드에서 piece는 `Piece` 타입이지만 dynamic으로 받아 캐스팅을 피함 — 구현 시 `import '../../game/models/piece.dart';``(Piece, double)`로 정확히 타이핑할 것.
- [ ] **Step 2: board_widget.dart에서 콤보 텍스트 제거**
`build()``Stack` children에서 `if (_flash.isAnimating && _comboStreak >= 2) Center(...)` 블록 전체와 `_comboStreak` 필드, `didUpdateWidget``_comboStreak = placement.comboStreak;` 라인을 삭제. (플래시 효과는 유지.)
- [ ] **Step 3: game_screen.dart 배선 — 이펙트 + 햅틱 + 흔들림**
상태 필드 추가 (`_GameScreenState`):
```dart
final _effectsKey = GlobalKey<EffectsOverlayState>();
late final AnimationController _shake = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
```
클래스 선언을 `with TickerProviderStateMixin`으로 변경(`ConsumerState<GameScreen>` 뒤에), `dispose` 오버라이드에서 `_shake.dispose()`.
`_onSessionChange`의 fxTick 분기 확장:
```dart
if (prev?.fxTick != next.fxTick && next.lastPlacement != null) {
final placement = next.lastPlacement!;
if (placement.linesCleared > 0) {
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
HapticFeedback.mediumImpact();
if (placement.comboStreak >= 4) {
HapticFeedback.heavyImpact();
_shake.forward(from: 0);
}
} else {
audio.play(Sfx.place);
HapticFeedback.lightImpact();
}
final boardBox = _boardBox;
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (boardBox != null && stackBox != null) {
final topLeft =
stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
_effectsKey.currentState?.onPlacement(
placement,
boardRect: topLeft & boardBox.size,
);
}
}
```
won 분기에 컨페티 추가:
```dart
if (next.phase == GamePhase.won) {
audio.play(Sfx.win);
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
_effectsKey.currentState?.onWin(stackBox.size);
}
// ... 기존 recordWin 호출 유지 ...
}
```
build의 SafeArea 내부 Stack children에서, 보드를 흔들림 래퍼로 감싸고(기존 `Expanded(child: Center(child: BoardWidget(...)))` 부분):
```dart
Expanded(
child: Center(
child: AnimatedBuilder(
animation: _shake,
builder: (context, child) {
final t = _shake.value;
final dx = math.sin(t * math.pi * 10) * 6 * (1 - t);
return Transform.translate(offset: Offset(dx, 0), child: child);
},
child: BoardWidget(key: _boardKey, view: view, ghost: ghost),
),
),
),
```
결과 오버레이 바로 앞에 이펙트 레이어 삽입 (드래그 오버레이 뒤, 결과 오버레이 앞):
```dart
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
```
import 추가:
```dart
import 'dart:math' as math;
import 'package:flutter/services.dart';
import '../widgets/effects_overlay.dart';
```
- [ ] **Step 4: 검증**
```
flutter analyze
flutter test
```
Expected: PASS (이펙트는 수명이 유한해서 pumpAndSettle 정상 종료).
- [ ] **Step 5: 커밋**
```bash
git add lib/ui
git commit -m "feat: juice kit - sparks, score popups, combo banners, confetti, haptics, shake"
```
---
### Task 6: 결과 오버레이 업그레이드 — 별 스태거 + 근접 실패 링
**Files:**
- Modify: `lib/ui/screens/game_screen.dart` (`_resultOverlay`)
- Modify: `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb`
- [ ] **Step 1: l10n 문자열 추가**
`app_en.arb`에 추가:
```json
"almostThere": "{percent}% complete!",
"@almostThere": {
"placeholders": { "percent": { "type": "int" } }
}
```
`app_ko.arb`에 추가:
```json
"almostThere": "{percent}% 달성!"
```
실행: `flutter gen-l10n`
- [ ] **Step 2: 별 스태거 위젯**
`_resultOverlay`의 별 Row를 교체:
```dart
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < 3; i++)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 400 + i * 250),
curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
builder: (context, v, child) =>
Transform.scale(scale: v, child: child),
child: Icon(
Icons.star,
size: 44,
color: i < view.starsEarned ? Colors.amber : Colors.white24,
),
),
],
),
```
- [ ] **Step 3: 근접 실패 링**
`_resultOverlay``(_, _)` (lost) 분기에서 카드 Column에, 제목 아래 추가할 위젯 (분기 구조상 actions 위에 삽입되도록 `Column` 빌드부에 조건 추가):
```dart
if (view.phase == GamePhase.lost && view.objectiveProgress > 0) ...[
const SizedBox(height: 16),
SizedBox(
width: 88,
height: 88,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: view.objectiveProgress,
strokeWidth: 7,
backgroundColor: Colors.white12,
color: Colors.amber,
),
Center(
child: Text(
l10n.almostThere(
(view.objectiveProgress * 100).round()),
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall,
),
),
],
),
),
],
```
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
```bash
git add lib/ui/screens/game_screen.dart lib/l10n
git commit -m "feat: staggered star reveal and near-miss progress ring"
```
---
### Task 7: 스플래시 화면 + 네이티브 런치 색
**Files:**
- Create: `lib/ui/screens/splash_screen.dart`
- Modify: `lib/app.dart` (home: SplashScreen)
- Modify: `ios/Runner/Base.lproj/LaunchScreen.storyboard`
- Modify: `android/app/src/main/res/drawable/launch_background.xml`, `drawable-v21/launch_background.xml`
- Modify: `test/widget_test.dart`
- [ ] **Step 1: widget_test를 스플래시 경유로 갱신 (RED)**
`test/widget_test.dart`의 본문을:
```dart
await tester.pumpWidget(
ProviderScope(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
child: const BlockSeasonsApp(),
),
);
// Splash (~1.9s) → season title card (~1.6s) → home.
await tester.pump(const Duration(milliseconds: 2100));
await tester.pump(const Duration(milliseconds: 2000));
await tester.pump(const Duration(milliseconds: 2000));
await tester.pumpAndSettle();
expect(find.text('Block Seasons'), findsOneWidget);
expect(find.text('Play'), findsOneWidget);
```
주: Task 8(시즌 카드) 전에는 스플래시→홈 직행이라 위 pump가 그대로 통과해야 함. 'Play' 문구는 Task 15에서 어드벤처/클래식으로 바뀌면 다시 갱신.
```
flutter test test/widget_test.dart
```
Expected: FAIL (아직 home이 HomeScreen 직행 — 스플래시 없음. 통과한다면 문구 그대로라서일 수 있으니 Step 2 후 재확인).
- [ ] **Step 2: splash_screen.dart 구현**
```dart
import 'package:flutter/material.dart';
import '../widgets/season_background.dart';
import 'home_screen.dart';
/// Logo-assembly splash: four glossy blocks fly in to form a 2x2 mark, the
/// wordmark fades in, then we hand off. SaveRepository is already opened in
/// main() so this doubles as perceived-zero loading time.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
/// Where the splash goes when finished; Task 8 swaps this to the season
/// title card.
static Widget Function() nextScreen = () => const HomeScreen();
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1900),
)..addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => SplashScreen.nextScreen()),
);
}
});
static const _blocks = [
(Color(0xFFFF7EB3), Offset(-1.2, -0.4), Offset(-0.5, -0.5)),
(Color(0xFFFFD166), Offset(1.2, -0.4), Offset(0.5, -0.5)),
(Color(0xFF6FCDF5), Offset(-1.2, 0.6), Offset(-0.5, 0.5)),
(Color(0xFF7EDB9C), Offset(1.2, 0.6), Offset(0.5, 0.5)),
];
@override
void initState() {
super.initState();
_c.forward();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const blockSize = 46.0;
const gap = 3.0;
return Scaffold(
backgroundColor: const Color(0xFF0E1430),
body: AnimatedBuilder(
animation: _c,
builder: (context, _) {
final titleT = const Interval(0.60, 0.88, curve: Curves.easeOut)
.transform(_c.value);
return Stack(
alignment: Alignment.center,
children: [
for (var i = 0; i < _blocks.length; i++)
_block(i, blockSize, gap),
Transform.translate(
offset: Offset(0, 78 + 12 * (1 - titleT)),
child: Opacity(
opacity: titleT,
child: const Text(
'BLOCK SEASONS',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
letterSpacing: 4,
color: Colors.white,
),
),
),
),
],
);
},
),
);
}
Widget _block(int i, double size, double gap) {
final (color, from, to) = _blocks[i];
final t = Interval(0.06 * i, 0.45 + 0.06 * i, curve: Curves.easeOutBack)
.transform(_c.value);
final unit = size / 2 + gap;
final begin = Offset(from.dx * 160, from.dy * 280);
final end = Offset(to.dx * unit * 2, to.dy * unit * 2) / 2 * 2;
final pos = Offset.lerp(begin, Offset(to.dx * (size + gap) , to.dy * (size + gap)) / 1, t)!;
return Transform.translate(
offset: pos / 1,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.lerp(color, Colors.white, 0.28)!,
color,
Color.lerp(color, Colors.black, 0.22)!,
],
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.55),
blurRadius: 18,
),
],
),
),
);
}
}
```
구현 시 `_block`의 좌표 산식은 단순화할 것: `begin = Offset(from.dx*160, from.dy*280)`, `end = Offset(to.dx*(size+gap), to.dy*(size+gap))`, `pos = Offset.lerp(begin, end, t)!`. (위 코드의 `/1`, `*2/2` 잔재 제거.)
`lib/app.dart`: `home: const HomeScreen()``home: const SplashScreen()`, import 교체.
- [ ] **Step 3: 네이티브 런치 색 통일 (#0E1430)**
iOS — `ios/Runner/Base.lproj/LaunchScreen.storyboard`에서
`<color key="backgroundColor" systemColor="systemBackgroundColor"/>` 줄을:
```xml
<color key="backgroundColor" red="0.054901960784" green="0.078431372549" blue="0.188235294118" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
```
Android — `android/app/src/main/res/drawable/launch_background.xml``drawable-v21/launch_background.xml` 둘 다 전체 내용을:
```xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#FF0E1430"/>
</shape>
</item>
</layer-list>
```
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
Expected: PASS (widget_test 포함).
```bash
git add lib test ios/Runner/Base.lproj android/app/src/main/res
git commit -m "feat: logo-assembly splash screen and native launch colors"
```
---
### Task 8: 시즌 타이틀 카드
**Files:**
- Create: `lib/ui/screens/season_title_screen.dart`
- Modify: `lib/ui/screens/splash_screen.dart` (`nextScreen` 교체)
- Modify: `lib/l10n/app_en.arb`, `app_ko.arb`
- Test: `test/ui/season_title_screen_test.dart`
- [ ] **Step 1: l10n 추가**
`app_en.arb`:
```json
"seasonLabel": "SEASON",
"seasonStages": "{count} stages",
"@seasonStages": {
"placeholders": { "count": { "type": "int" } }
}
```
`app_ko.arb`:
```json
"seasonLabel": "SEASON",
"seasonStages": "{count}개 스테이지"
```
`flutter gen-l10n` 실행.
- [ ] **Step 2: 실패하는 테스트**
`test/ui/season_title_screen_test.dart`:
```dart
import 'package:block_seasons/game/models/season.dart';
import 'package:block_seasons/game/models/stage.dart';
import 'package:block_seasons/l10n/gen/app_localizations.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:block_seasons/ui/screens/home_screen.dart';
import 'package:block_seasons/ui/screens/season_title_screen.dart';
import 'package:block_seasons/data/save_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
SeasonPack _pack() => SeasonPack(
schemaVersion: 1,
seasonId: 'season_001',
version: 1,
title: const {'en': 'First Bloom', 'ko': '첫 개화'},
theme: SeasonTheme.fallback,
stages: [
StageConfig(
id: 's1',
seed: 1,
moveLimit: 10,
preset: const [],
objectives: const [],
stars: const StarThresholds(twoMovesLeft: 2, threeMovesLeft: 4),
generatorProfile: 'mid',
),
],
);
Widget _app(SaveRepository repo) => ProviderScope(
overrides: [
saveRepositoryProvider.overrideWithValue(repo),
seasonsProvider.overrideWith((ref) async => [_pack()]),
],
child: const MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SeasonTitleScreen(),
),
);
void main() {
testWidgets('shows season title then auto-advances to home',
(tester) async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
await tester.pumpWidget(_app(repo));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('First Bloom'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
testWidgets('tap skips immediately', (tester) async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
await tester.pumpWidget(_app(repo));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('First Bloom'));
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
});
}
```
```
flutter test test/ui/season_title_screen_test.dart
```
Expected: FAIL — 파일 없음.
- [ ] **Step 3: 구현**
`lib/ui/screens/season_title_screen.dart`:
```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
import '../widgets/season_background.dart';
import 'home_screen.dart';
/// Cold-start interstitial: "SEASON 1 · First Bloom". Tap anywhere or wait
/// ~1.6s. If content somehow fails to load we bail straight to home.
class SeasonTitleScreen extends ConsumerStatefulWidget {
const SeasonTitleScreen({super.key});
@override
ConsumerState<SeasonTitleScreen> createState() =>
_SeasonTitleScreenState();
}
class _SeasonTitleScreenState extends ConsumerState<SeasonTitleScreen> {
Timer? _auto;
bool _navigated = false;
void _go() {
if (_navigated || !mounted) return;
_navigated = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
@override
void dispose() {
_auto?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final seasons = ref.watch(seasonsProvider);
final l10n = AppLocalizations.of(context)!;
return seasons.when(
loading: () {
_auto ??= Timer(const Duration(milliseconds: 2500), _go);
return const Scaffold(backgroundColor: Color(0xFF0E1430), body: SizedBox());
},
error: (_, __) {
WidgetsBinding.instance.addPostFrameCallback((_) => _go());
return const Scaffold(backgroundColor: Color(0xFF0E1430), body: SizedBox());
},
data: (list) {
_auto?.cancel();
_auto = Timer(const Duration(milliseconds: 1600), _go);
final pack = list.first;
final locale = Localizations.localeOf(context).languageCode;
final number =
int.tryParse(pack.seasonId.split('_').last) ?? 1;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _go,
child: Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
SeasonBackground(theme: pack.theme),
SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${l10n.seasonLabel} $number',
style: TextStyle(
letterSpacing: 6,
fontSize: 14,
color: Colors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: 10),
Text(
pack.titleFor(locale),
style: const TextStyle(
fontSize: 38,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
const SizedBox(height: 8),
Text(
l10n.seasonStages(pack.stages.length),
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
),
),
],
),
),
);
},
);
}
}
```
`splash_screen.dart`: `nextScreen = () => const SeasonTitleScreen();` 로 기본값 교체 (import 추가, HomeScreen import 제거 가능).
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
Expected: PASS (widget_test의 여유 pump가 카드 1.6초를 흡수).
```bash
git add lib test
git commit -m "feat: season title card on cold start"
```
---
### Task 9: 튜토리얼 상태 머신 (순수 Dart + Notifier)
**Files:**
- Create: `lib/state/tutorial_notifier.dart`
- Modify: `lib/state/providers.dart`
- Test: `test/state/tutorial_notifier_test.dart`
- [ ] **Step 1: 실패하는 테스트**
`test/state/tutorial_notifier_test.dart`:
```dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:block_seasons/state/tutorial_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late ProviderContainer container;
late SaveRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
repo = SaveRepository(await SharedPreferences.getInstance());
container = ProviderContainer(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
);
});
tearDown(() => container.dispose());
test('inactive by default', () {
expect(container.read(tutorialProvider), isNull);
});
test('happy path: drag -> clear -> hud -> done, persists flag', () async {
final n = container.read(tutorialProvider.notifier);
n.start();
expect(container.read(tutorialProvider), TutorialStep.dragPiece);
n.onPlaced();
expect(container.read(tutorialProvider), TutorialStep.clearLine);
n.onLineCleared();
expect(container.read(tutorialProvider), TutorialStep.explainHud);
await n.dismissHud();
expect(container.read(tutorialProvider), isNull);
expect(repo.tutorialDone, isTrue);
});
test('out-of-order events are ignored', () {
final n = container.read(tutorialProvider.notifier);
n.start();
n.onLineCleared(); // not in clearLine step yet
expect(container.read(tutorialProvider), TutorialStep.dragPiece);
});
test('skip finishes from any step and persists', () async {
final n = container.read(tutorialProvider.notifier);
n.start();
await n.skip();
expect(container.read(tutorialProvider), isNull);
expect(repo.tutorialDone, isTrue);
});
test('start is a no-op when tutorial already done', () async {
await repo.markTutorialDone();
final n = container.read(tutorialProvider.notifier);
n.start();
expect(container.read(tutorialProvider), isNull);
});
}
```
```
flutter test test/state/tutorial_notifier_test.dart
```
Expected: FAIL.
- [ ] **Step 2: 구현**
`lib/state/tutorial_notifier.dart`:
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
enum TutorialStep { dragPiece, clearLine, explainHud }
/// First-play guided tutorial. State null = inactive. Events arriving in the
/// wrong step are ignored, so engine wiring can fire them unconditionally.
class TutorialNotifier extends Notifier<TutorialStep?> {
@override
TutorialStep? build() => null;
void start() {
if (ref.read(saveRepositoryProvider).tutorialDone) return;
state = TutorialStep.dragPiece;
}
void onPlaced() {
if (state == TutorialStep.dragPiece) state = TutorialStep.clearLine;
}
void onLineCleared() {
if (state == TutorialStep.clearLine) state = TutorialStep.explainHud;
}
Future<void> dismissHud() async {
if (state != TutorialStep.explainHud) return;
await _finish();
}
Future<void> skip() async {
if (state == null) return;
await _finish();
}
Future<void> _finish() async {
state = null;
await ref.read(saveRepositoryProvider).markTutorialDone();
}
}
```
`providers.dart`에 추가:
```dart
final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
TutorialNotifier.new,
);
```
import: `import 'tutorial_notifier.dart';` + export 편의상 `export 'tutorial_notifier.dart' show TutorialStep;` 는 불필요 — 테스트가 직접 import.
- [ ] **Step 3: 통과 + 커밋**
```
flutter test test/state/
```
```bash
git add lib/state test/state/tutorial_notifier_test.dart
git commit -m "feat: tutorial step state machine with persistence"
```
---
### Task 10: 튜토리얼 오버레이 + 게임 화면 통합
**Files:**
- Create: `lib/ui/widgets/tutorial_overlay.dart`
- Modify: `lib/ui/screens/game_screen.dart`
- Modify: `lib/l10n/app_en.arb`, `app_ko.arb`
- [ ] **Step 1: l10n 추가**
`app_en.arb`:
```json
"skip": "Skip",
"gotIt": "Got it!",
"tutorialDrag": "Drag a block onto the board!",
"tutorialClear": "Fill a row or column to clear it!",
"tutorialHud": "Hit the goal before you run out of moves. Your turn!"
```
`app_ko.arb`:
```json
"skip": "건너뛰기",
"gotIt": "알겠어요!",
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!"
```
`flutter gen-l10n`.
- [ ] **Step 2: tutorial_overlay.dart 구현**
```dart
import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/tutorial_notifier.dart';
import 'season_background.dart' show debugDisableLoopingAnimations;
/// Non-blocking guidance overlay: dim veil, message bubble, animated hand on
/// the drag step, skip always available. Input still reaches the game so the
/// player advances by actually doing the action.
class TutorialOverlay extends StatefulWidget {
const TutorialOverlay({
super.key,
required this.step,
required this.handFrom,
required this.handTo,
required this.onSkip,
required this.onDismissHud,
});
final TutorialStep step;
/// Global → local-to-overlay coordinates of the hand animation path
/// (tray slot 0 → suggested board anchor). Only used on dragPiece.
final Offset handFrom;
final Offset handTo;
final VoidCallback onSkip;
final VoidCallback onDismissHud;
@override
State<TutorialOverlay> createState() => _TutorialOverlayState();
}
class _TutorialOverlayState extends State<TutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _hand = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
@override
void initState() {
super.initState();
if (!debugDisableLoopingAnimations) _hand.repeat();
}
@override
void dispose() {
_hand.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final message = switch (widget.step) {
TutorialStep.dragPiece => l10n.tutorialDrag,
TutorialStep.clearLine => l10n.tutorialClear,
TutorialStep.explainHud => l10n.tutorialHud,
};
return Stack(
children: [
// Veil that lets touches through.
IgnorePointer(
child: Container(color: Colors.black.withValues(alpha: 0.25)),
),
if (widget.step == TutorialStep.dragPiece)
IgnorePointer(
child: AnimatedBuilder(
animation: _hand,
builder: (context, _) {
final t = Curves.easeInOut.transform(_hand.value);
final pos = Offset.lerp(widget.handFrom, widget.handTo, t)!;
return Positioned(
left: pos.dx,
top: pos.dy,
child: Opacity(
opacity: _hand.value < 0.9 ? 1 : (1 - _hand.value) * 10,
child: const Icon(Icons.touch_app,
size: 44, color: Colors.white),
),
);
},
),
),
Positioned(
top: 70,
left: 24,
right: 24,
child: Card(
color: const Color(0xEE1C2340),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
if (widget.step == TutorialStep.explainHud) ...[
const SizedBox(height: 10),
FilledButton(
onPressed: widget.onDismissHud,
child: Text(l10n.gotIt),
),
],
],
),
),
),
),
Positioned(
top: 8,
right: 8,
child: TextButton(
onPressed: widget.onSkip,
child: Text(l10n.skip,
style: const TextStyle(color: Colors.white54)),
),
),
],
);
}
}
```
주의: `Positioned``Stack`의 직계 자식이어야 하므로 hand의 `AnimatedBuilder` 구조를 구현 시 조정 — `Positioned.fill` 안에 `CustomPaint`로 손 아이콘을 그리거나, `AnimatedBuilder`를 바깥으로 빼고 `Positioned(left: pos.dx, ...)`가 직접 Stack 자식이 되게 할 것. 권장 구조:
```dart
AnimatedBuilder(
animation: _hand,
builder: (context, _) {
final t = Curves.easeInOut.transform(_hand.value);
final pos = Offset.lerp(widget.handFrom, widget.handTo, t)!;
return Positioned(left: pos.dx, top: pos.dy, child: IgnorePointer(child: Icon(...)));
},
)
```
(AnimatedBuilder가 Stack 직계여도 Positioned를 반환하면 Stack이 인식하지 못함 — 따라서 `Positioned.fill > IgnorePointer > Stack > Transform.translate(offset: pos)` 구조로 구현하는 것이 안전.)
- [ ] **Step 3: game_screen.dart 통합**
`_GameScreenState`에:
```dart
bool _tutorialStartChecked = false;
```
build 시작부(view null 체크 뒤)에:
```dart
final tutorialStep = ref.watch(tutorialProvider);
if (!_tutorialStartChecked) {
_tutorialStartChecked = true;
final flow = ref.read(seasonFlowProvider);
if (flow != null &&
flow.index == 0 &&
!ref.read(saveRepositoryProvider).tutorialDone) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.read(tutorialProvider.notifier).start());
}
}
```
`_onSessionChange`의 fxTick 분기 안(이펙트 호출 근처)에 추가:
```dart
ref.read(tutorialProvider.notifier).onPlaced();
if (placement.linesCleared > 0) {
ref.read(tutorialProvider.notifier).onLineCleared();
}
```
Stack children 끝(닫기 버튼 앞)에:
```dart
if (tutorialStep != null)
Positioned.fill(
child: TutorialOverlay(
step: tutorialStep,
handFrom: _tutorialHandFrom(),
handTo: _tutorialHandTo(view),
onSkip: () => ref.read(tutorialProvider.notifier).skip(),
onDismissHud: () =>
ref.read(tutorialProvider.notifier).dismissHud(),
),
),
```
헬퍼 (클래스 안):
```dart
Offset _tutorialHandFrom() {
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox == null) return Offset.zero;
// Tray sits at the bottom; aim at the left slot.
final size = stackBox.size;
return Offset(size.width * 0.18, size.height - 80);
}
Offset _tutorialHandTo(GameViewState view) {
final boardBox = _boardBox;
final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (boardBox == null || stackBox == null || view.tray.isEmpty) {
return Offset.zero;
}
final geo = BoardGeometry(boardSize: boardBox.size.width);
final notifier = ref.read(gameSessionProvider.notifier);
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (notifier.canPlaceAt(0, x, y)) {
final local = Offset(
(x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize);
return stackBox
.globalToLocal(boardBox.localToGlobal(local));
}
}
}
return Offset.zero;
}
```
import 추가: `import '../../game/models/grid.dart';`, `import '../../state/tutorial_notifier.dart';`, `import '../widgets/tutorial_overlay.dart';`, `import '../widgets/board_geometry.dart';`(이미 있으면 생략).
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
주의: 기존 `game_screen_test.dart`가 첫 플레이 조건과 무관하게 동작하는지 확인 — 그 테스트는 seasonFlow 없이 stage를 직접 시작하므로 `flow == null` → 튜토리얼 미발동, 영향 없음.
```bash
git add lib test
git commit -m "feat: first-play interactive tutorial overlay"
```
---
### Task 11: 경로 맵 레이아웃 함수
**Files:**
- Create: `lib/ui/widgets/map_layout.dart`
- Test: `test/ui/map_layout_test.dart`
- [ ] **Step 1: 실패하는 테스트**
`test/ui/map_layout_test.dart`:
```dart
import 'package:block_seasons/ui/widgets/map_layout.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const layout = MapLayout(width: 400);
test('node 0 sits near the bottom, later nodes climb', () {
final h = layout.heightFor(60);
final first = layout.nodeCenter(0, 60);
final last = layout.nodeCenter(59, 60);
expect(first.dy, greaterThan(h - 200));
expect(last.dy, lessThan(200));
for (var i = 1; i < 60; i++) {
expect(layout.nodeCenter(i, 60).dy,
lessThan(layout.nodeCenter(i - 1, 60).dy));
}
});
test('x stays within horizontal margins', () {
for (var i = 0; i < 60; i++) {
final x = layout.nodeCenter(i, 60).dx;
expect(x, greaterThanOrEqualTo(400 * 0.12));
expect(x, lessThanOrEqualTo(400 * 0.88));
}
});
test('vertical spacing is uniform', () {
final a = layout.nodeCenter(3, 60).dy - layout.nodeCenter(4, 60).dy;
final b = layout.nodeCenter(40, 60).dy - layout.nodeCenter(41, 60).dy;
expect(a, closeTo(b, 0.001));
});
}
```
```
flutter test test/ui/map_layout_test.dart
```
Expected: FAIL.
- [ ] **Step 2: 구현**
`lib/ui/widgets/map_layout.dart`:
```dart
import 'dart:math' as math;
import 'dart:ui';
/// Deterministic serpentine layout for the journey map. Stage 0 is at the
/// bottom; the path snakes upward. Works for any stage count.
class MapLayout {
const MapLayout({
required this.width,
this.nodeSpacing = 108,
this.topPadding = 140,
this.bottomPadding = 150,
});
final double width;
final double nodeSpacing;
final double topPadding;
final double bottomPadding;
double get amplitude => width * 0.26;
double heightFor(int count) =>
topPadding + bottomPadding + (count - 1) * nodeSpacing;
Offset nodeCenter(int index, int count) {
final y = heightFor(count) - bottomPadding - index * nodeSpacing;
final x = width / 2 + amplitude * math.sin(index * 1.05);
return Offset(x, y);
}
}
```
- [ ] **Step 3: 통과 + 커밋**
```
flutter test test/ui/map_layout_test.dart
```
```bash
git add lib/ui/widgets/map_layout.dart test/ui/map_layout_test.dart
git commit -m "feat: serpentine map layout function"
```
---
### Task 12: 여정 경로 맵 화면 (season_map_screen.dart 교체)
**Files:**
- Modify: `lib/ui/screens/season_map_screen.dart` (전면 재작성)
- Modify: `test/ui/season_map_screen_test.dart`
- [ ] **Step 1: 기존 테스트를 새 UI에 맞게 갱신 (RED)**
`test/ui/season_map_screen_test.dart`에서 — 기존 테스트의 핵심 시나리오(별 표시, 잠금, 탭 → 게임 진입)는 유지하되 그리드 전제 부분을 수정. 노드는 `Key('stage_node_$i')`로 찾도록 변경:
```dart
// 기존 expect들을 다음 패턴으로 교체:
expect(find.byKey(const Key('stage_node_0')), findsOneWidget);
// 잠긴 노드:
expect(
find.descendant(
of: find.byKey(const Key('stage_node_2')),
matching: find.byIcon(Icons.lock),
),
findsOneWidget,
);
// 탭:
await tester.tap(find.byKey(const Key('stage_node_0')));
```
(파일의 기존 골격 — `_pack()` 헬퍼, ProviderScope 오버라이드 — 는 그대로 활용.)
```
flutter test test/ui/season_map_screen_test.dart
```
Expected: FAIL (Key 없음).
- [ ] **Step 2: season_map_screen.dart 전면 재작성**
```dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/models/season.dart';
import '../../state/providers.dart';
import '../theme/palette.dart';
import '../widgets/map_layout.dart';
import '../widgets/season_background.dart';
import '../widgets/tile_painter.dart';
import 'game_screen.dart';
/// Journey map: a serpentine path of stage nodes climbing the season
/// illustration. Auto-scrolls to the current stage on entry.
class SeasonMapScreen extends ConsumerWidget {
const SeasonMapScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final seasons = ref.watch(seasonsProvider);
return seasons.when(
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
data: (list) => _JourneyMap(pack: list.first),
);
}
}
class _JourneyMap extends ConsumerStatefulWidget {
const _JourneyMap({required this.pack});
final SeasonPack pack;
@override
ConsumerState<_JourneyMap> createState() => _JourneyMapState();
}
class _JourneyMapState extends ConsumerState<_JourneyMap> {
final _scroll = ScrollController();
bool _autoScrolled = false;
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _autoScrollTo(MapLayout layout, int current, int count,
double viewportHeight) {
if (_autoScrolled || !_scroll.hasClients) return;
_autoScrolled = true;
final contentH = layout.heightFor(count);
final target = (contentH -
layout.nodeCenter(current, count).dy -
viewportHeight / 2)
.clamp(0.0, _scroll.position.maxScrollExtent);
_scroll.jumpTo(target);
}
@override
Widget build(BuildContext context) {
ref.watch(progressProvider);
final pack = widget.pack;
final repo = ref.read(saveRepositoryProvider);
final ids = [for (final stage in pack.stages) stage.id];
final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids);
final totalStars = repo.totalStars(pack.seasonId);
final locale = Localizations.localeOf(context).languageCode;
final colors = ThemeColors(pack.theme);
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
fit: StackFit.expand,
children: [
SeasonBackground(theme: pack.theme),
LayoutBuilder(
builder: (context, constraints) {
final layout = MapLayout(width: constraints.maxWidth);
final count = pack.stages.length;
WidgetsBinding.instance.addPostFrameCallback((_) =>
_autoScrollTo(
layout, unlocked, count, constraints.maxHeight));
return SingleChildScrollView(
controller: _scroll,
reverse: true,
child: SizedBox(
width: constraints.maxWidth,
height: layout.heightFor(count),
child: Stack(
children: [
CustomPaint(
size: Size(constraints.maxWidth,
layout.heightFor(count)),
painter: _PathPainter(layout: layout, count: count),
),
for (var i = 0; i < count; i++)
_node(context, layout, i, count, unlocked,
repo.progressFor(pack.seasonId, ids[i])?.stars ??
0,
colors),
],
),
),
);
},
),
// Glass header.
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 6,
bottom: 12,
left: 8,
right: 16,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.45),
Colors.transparent,
],
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
pack.titleFor(locale),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
),
Text(
'$totalStars/${pack.stages.length * 3}',
style: const TextStyle(
color: Colors.amber,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
],
),
);
}
Widget _node(BuildContext context, MapLayout layout, int i, int count,
int unlocked, int stars, ThemeColors colors) {
final center = layout.nodeCenter(i, count);
final isCurrent = i == unlocked;
final isUnlocked = i <= unlocked;
final size = isCurrent ? 64.0 : 52.0;
return Positioned(
key: Key('stage_node_$i'),
left: center.dx - size / 2,
top: center.dy - size / 2,
child: GestureDetector(
onTap: !isUnlocked
? null
: () {
ref
.read(seasonFlowProvider.notifier)
.startSeasonStage(widget.pack, i);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const GameScreen()),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: isUnlocked
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isCurrent
? [
lighten(colors.accent, 0.25),
colors.accent,
darken(colors.accent, 0.2),
]
: [
const Color(0xFFFFE9A8),
const Color(0xFFFFD166),
const Color(0xFFE0AC3B),
],
)
: null,
color: isUnlocked ? null : const Color(0xFF232B4A),
boxShadow: isCurrent
? [
BoxShadow(
color: colors.accent.withValues(alpha: 0.7),
blurRadius: 22,
),
]
: null,
),
child: isUnlocked
? Text(
'${i + 1}',
style: TextStyle(
fontSize: isCurrent ? 22 : 17,
fontWeight: FontWeight.w900,
color: isCurrent
? Colors.white
: const Color(0xFF5A4200),
),
)
: const Icon(Icons.lock,
color: Colors.white24, size: 20),
),
if (isUnlocked && !isCurrent)
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var s = 0; s < 3; s++)
Icon(
Icons.star,
size: 13,
color:
s < stars ? Colors.amber : Colors.white24,
),
],
),
],
),
),
);
}
}
class _PathPainter extends CustomPainter {
const _PathPainter({required this.layout, required this.count});
final MapLayout layout;
final int count;
@override
void paint(Canvas canvas, Size size) {
if (count < 2) return;
final path = Path()
..moveTo(layout.nodeCenter(0, count).dx, layout.nodeCenter(0, count).dy);
for (var i = 1; i < count; i++) {
final prev = layout.nodeCenter(i - 1, count);
final cur = layout.nodeCenter(i, count);
final midY = (prev.dy + cur.dy) / 2;
path.cubicTo(prev.dx, midY, cur.dx, midY, cur.dx, cur.dy);
}
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.25)
..style = PaintingStyle.stroke
..strokeWidth = 5
..strokeCap = StrokeCap.round;
// Dash the path manually: 1px dots every 12px.
for (final metric in path.computeMetrics()) {
var d = 0.0;
while (d < metric.length) {
canvas.drawPath(metric.extractPath(d, d + 1.5), paint);
d += 13;
}
}
}
@override
bool shouldRepaint(_PathPainter old) =>
old.count != count || old.layout.width != layout.width;
}
```
- [ ] **Step 3: 검증 + 커밋**
```
flutter analyze && flutter test
```
Expected: PASS.
```bash
git add lib/ui/screens/season_map_screen.dart test/ui/season_map_screen_test.dart
git commit -m "feat: serpentine journey map with auto-scroll and glowing current node"
```
---
### Task 13: 엔드리스 모드 — 엔진
**Files:**
- Modify: `lib/game/models/stage.dart` (endless 플래그 + 팩토리)
- Modify: `lib/game/engine/game_engine.dart`
- Test: `test/game/engine/endless_test.dart`
- [ ] **Step 1: 실패하는 테스트**
`test/game/engine/endless_test.dart`:
```dart
import 'package:block_seasons/game/engine/game_engine.dart';
import 'package:block_seasons/game/models/grid.dart';
import 'package:block_seasons/game/models/stage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('endless stage config has no objectives and the endless flag', () {
final stage = StageConfig.endless(seed: 42);
expect(stage.endless, isTrue);
expect(stage.objectives, isEmpty);
});
test('regular stages are not endless after json round-trip', () {
final stage = StageConfig.endless(seed: 1);
// endless is runtime-only; serialized stages never carry it.
expect(StageConfig.fromJson(stage.toJson()).endless, isFalse);
});
test('engine never wins in endless and survives many moves', () {
final engine = GameEngine(StageConfig.endless(seed: 7));
var moves = 0;
outer:
while (engine.phase == GamePhase.playing && moves < 300) {
for (var i = 0; i < engine.tray.length; i++) {
for (var y = 0; y < GridState.size; y++) {
for (var x = 0; x < GridState.size; x++) {
if (engine.tryPlaceWouldSucceed(i, x, y)) {
engine.tryPlace(i, x, y);
moves++;
continue outer;
}
}
}
}
break;
}
expect(engine.phase, isNot(GamePhase.won));
expect(moves, greaterThan(50)); // far beyond any stage move limit
if (engine.phase == GamePhase.stuck) {
expect(engine.stuckReason, StuckReason.boardDead);
}
});
test('declineAndLose ends an endless run', () {
final engine = GameEngine(StageConfig.endless(seed: 7));
engine.declineAndLose();
expect(engine.phase, GamePhase.lost);
});
}
```
```
flutter test test/game/engine/endless_test.dart
```
Expected: FAIL.
- [ ] **Step 2: stage.dart 구현**
`StageConfig`에 필드/팩토리 추가:
```dart
// 생성자 파라미터에 추가:
this.endless = false,
// 필드:
/// Runtime-only: score-attack mode with no objectives or move limit.
/// Never serialized — packs always describe objective stages.
final bool endless;
// 팩토리:
factory StageConfig.endless({required int seed}) => StageConfig(
id: 'endless',
seed: seed,
moveLimit: 0,
preset: const [],
objectives: const [],
stars: const StarThresholds(
twoMovesLeft: 1 << 30, threeMovesLeft: 1 << 30),
generatorProfile: 'mid',
endless: true,
);
```
(`fromJson`/`toJson`은 변경하지 않음 — endless는 직렬화에서 항상 false.)
- [ ] **Step 3: game_engine.dart 구현**
세 군데 수정:
```dart
// movesLeft: 엔드리스는 사실상 무한.
int get movesLeft =>
_stage.endless ? 1 << 30 : _moveLimit - _movesUsed;
// 엔드리스 노출 (UI에서 분기용):
bool get endless => _stage.endless;
// tryPlace의 승리 판정:
if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
_phase = GamePhase.won;
} else {
...
}
// _checkStuck의 outOfMoves 분기:
if (!_stage.endless && movesLeft <= 0) {
...
```
- [ ] **Step 4: 통과 + 전체 테스트 + 커밋**
```
flutter test test/game/
flutter test
```
```bash
git add lib/game test/game/engine/endless_test.dart
git commit -m "feat: endless score-attack mode in the engine"
```
---
### Task 14: 엔드리스 UI — 게임오버 / 베스트 / HUD
**Files:**
- Modify: `lib/state/game_session_notifier.dart` (endless 플래그 노출)
- Create: `lib/state/endless_best_notifier.dart`
- Modify: `lib/state/providers.dart`
- Modify: `lib/ui/widgets/hud_widget.dart` (이동 칩 숨김)
- Modify: `lib/ui/screens/game_screen.dart` (게임오버 오버레이 + 베스트 기록)
- Modify: `lib/l10n/app_en.arb`, `app_ko.arb`
- Test: `test/state/endless_best_test.dart`
- [ ] **Step 1: l10n 추가**
`app_en.arb`:
```json
"gameOver": "Game Over",
"bestScore": "Best {score}",
"@bestScore": { "placeholders": { "score": { "type": "int" } } },
"newBest": "NEW BEST!"
```
`app_ko.arb`:
```json
"gameOver": "게임 오버",
"bestScore": "최고 {score}",
"newBest": "신기록!"
```
`flutter gen-l10n`.
- [ ] **Step 2: 실패하는 테스트 (베스트 노티파이어)**
`test/state/endless_best_test.dart`:
```dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('exposes saved best and reports new records', () async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
await repo.recordEndlessScore(400);
final container = ProviderContainer(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
expect(container.read(endlessBestProvider), 400);
final n = container.read(endlessBestProvider.notifier);
expect(await n.record(300), isFalse);
expect(container.read(endlessBestProvider), 400);
expect(await n.record(900), isTrue);
expect(container.read(endlessBestProvider), 900);
});
}
```
```
flutter test test/state/endless_best_test.dart
```
Expected: FAIL.
- [ ] **Step 3: 구현**
`lib/state/endless_best_notifier.dart`:
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// Reactive view over SaveRepository's endless best score.
class EndlessBestNotifier extends Notifier<int> {
@override
int build() => ref.read(saveRepositoryProvider).endlessBest;
/// Records the run; returns true when it set a new best.
Future<bool> record(int score) async {
final repo = ref.read(saveRepositoryProvider);
final isNewBest = score > state;
await repo.recordEndlessScore(score);
state = repo.endlessBest;
return isNewBest;
}
}
```
`providers.dart`:
```dart
final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
EndlessBestNotifier.new,
);
```
(+ `import 'endless_best_notifier.dart';`)
`game_session_notifier.dart``GameViewState``required this.endless` / `final bool endless` 추가, `_publish`에서 `endless: engine.endless` 전달 (엔진 getter는 Task 13에서 추가됨). **이 클래스를 생성하는 기존 테스트가 있으면 같이 갱신.**
`hud_widget.dart` — 이동 칩 분기:
```dart
// Row의 첫 자식:
view.endless ? const SizedBox(width: 48) : _movesChip(theme),
```
`game_screen.dart`:
(a) 상태 필드 `bool _endlessNewBest = false;`
(b) `_onSessionChange`의 phase 전환부에 추가:
```dart
if (next.phase == GamePhase.lost && next.endless) {
ref
.read(endlessBestProvider.notifier)
.record(next.score)
.then((isNew) {
if (mounted) setState(() => _endlessNewBest = isNew);
});
}
```
(c) `_resultOverlay`의 switch에서 lost+endless 분기를 `(_, _)` 보다 먼저 추가:
```dart
(GamePhase.lost, _) when view.endless => (
l10n.gameOver,
[
FilledButton(
onPressed: () {
setState(() => _endlessNewBest = false);
notifier.restart();
},
child: Text(l10n.playAgain),
),
],
),
```
그리고 카드 Column에 (별/근접실패 링과 같은 위치 패턴으로):
```dart
if (view.phase == GamePhase.lost && view.endless) ...[
const SizedBox(height: 10),
Text('${view.score}',
style: theme.textTheme.displaySmall
?.copyWith(fontWeight: FontWeight.w900)),
const SizedBox(height: 4),
Text(
_endlessNewBest
? l10n.newBest
: l10n.bestScore(ref.read(endlessBestProvider)),
style: TextStyle(
color: _endlessNewBest ? Colors.amber : Colors.white60,
fontWeight: FontWeight.w800,
),
),
],
```
근접 실패 링 조건에 `&& !view.endless` 추가.
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
```bash
git add lib test
git commit -m "feat: endless mode UI - game over card, best score, HUD"
```
---
### Task 15: 홈 화면 리디자인 + 어드벤처/클래식 진입
**Files:**
- Modify: `lib/ui/screens/home_screen.dart` (전면 재작성)
- Modify: `lib/l10n/app_en.arb`, `app_ko.arb`
- Modify: `test/widget_test.dart` (버튼 문구 갱신)
- [ ] **Step 1: l10n 추가**
`app_en.arb`: `"adventure": "Adventure"`, `"classic": "Classic"`
`app_ko.arb`: `"adventure": "어드벤처"`, `"classic": "클래식"`
`flutter gen-l10n`.
- [ ] **Step 2: widget_test 갱신 (RED)**
```dart
expect(find.text('Block Seasons'), findsOneWidget);
expect(find.text('Adventure'), findsOneWidget);
expect(find.text('Classic'), findsOneWidget);
```
```
flutter test test/widget_test.dart
```
Expected: FAIL ('Adventure' 없음).
- [ ] **Step 3: home_screen.dart 재작성**
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/models/season.dart';
import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
import '../theme/palette.dart';
import '../widgets/season_background.dart';
import 'game_screen.dart';
import 'season_map_screen.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
static const _logoColors = [
Color(0xFFFF7EB3),
Color(0xFFFFD166),
Color(0xFF6FCDF5),
Color(0xFF7EDB9C),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final streak = ref.watch(streakProvider);
final best = ref.watch(endlessBestProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
fit: StackFit.expand,
children: [
const SeasonBackground(theme: SeasonTheme.fallback),
SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_logoMark(),
const SizedBox(height: 18),
Text(
l10n.appTitle,
style: Theme.of(context)
.textTheme
.displaySmall
?.copyWith(fontWeight: FontWeight.w900),
),
if (streak.current > 0) ...[
const SizedBox(height: 10),
Chip(
avatar: const Icon(
Icons.local_fire_department,
color: Colors.deepOrange,
size: 20,
),
label: Text(
'${streak.current}',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
const SizedBox(height: 44),
FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 56, vertical: 18),
textStyle: Theme.of(context).textTheme.titleLarge,
),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SeasonMapScreen()),
),
child: Text(l10n.adventure),
),
const SizedBox(height: 14),
OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 40, vertical: 14),
textStyle: Theme.of(context).textTheme.titleMedium,
),
onPressed: () {
ref.read(gameSessionProvider.notifier).startStage(
StageConfig.endless(
seed: DateTime.now().millisecondsSinceEpoch,
),
);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const GameScreen()),
);
},
child: Text(l10n.classic),
),
if (best > 0) ...[
const SizedBox(height: 10),
Text(
l10n.bestScore(best),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
),
),
],
],
),
),
),
],
),
);
}
Widget _logoMark() {
return SizedBox(
width: 96,
height: 96,
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
physics: const NeverScrollableScrollPhysics(),
children: [
for (final color in _logoColors)
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.lerp(color, Colors.white, 0.28)!,
color,
Color.lerp(color, Colors.black, 0.22)!,
],
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.45),
blurRadius: 14,
),
],
),
),
],
),
);
}
}
```
주의: 클래식 진입 시 `seasonFlowProvider`가 이전 시즌 상태를 들고 있으면 게임 화면의 won 분기가 `recordWin`을 부를 수 있으나, 엔드리스는 won이 되지 않으므로 무해. 단 `activeThemeProvider`는 시즌 테마를 반환할 수 있음 — 의도된 동작(직전 시즌 분위기 유지)으로 간주.
- [ ] **Step 4: 검증 + 커밋**
```
flutter analyze && flutter test
```
Expected: PASS.
```bash
git add lib test
git commit -m "feat: redesigned home with adventure/classic entries and endless best"
```
---
### Task 16: 통합 검증 — 시뮬레이터 + 푸시
**Files:** 없음 (검증/커밋만)
- [ ] **Step 1: 풀 스위트**
```
flutter analyze
flutter test
```
Expected: 0 issues, 전체 PASS.
- [ ] **Step 2: iOS 시뮬레이터 빌드 & 실행**
```bash
xcrun simctl boot "iPhone 17 Pro" 2>/dev/null; flutter build ios --simulator --debug
xcrun simctl install booted build/ios/iphonesimulator/Runner.app
xcrun simctl launch booted com.airkjw.blockseasons
```
수동 체크리스트 (스크린샷 저장: `docs/screenshots/`):
1. 콜드 스타트: 네이비 네이티브 스플래시 → 로고 조립 → 시즌 카드 → 홈 (흰 화면 플래시 없어야 함)
2. 신규 설치(앱 삭제 후 재설치): 어드벤처 → 스테이지 1 → 튜토리얼 3스텝 작동, 스킵 작동
3. 줄 클리어: 스파크 + 점수 팝업 + 콤보 배너, 콤보 4+에서 화면 흔들림
4. 승리: 별 스태거 + 컨페티 → "다음 스테이지" → 맵 복귀 시 다음 노드 발광
5. 여정 맵: 현재 노드로 자동 스크롤, 위로 스크롤 시 잠긴 노드들
6. 클래식: 홈 → 즉시 시작, 이동 칩 없음, 게임오버 카드에 점수/베스트
7. 패배(스테이지): 근접 실패 링 표시
```bash
xcrun simctl io booted screenshot docs/screenshots/sim_polish_home.png
xcrun simctl io booted screenshot docs/screenshots/sim_polish_map.png
```
(각 화면을 띄운 시점에 촬영.)
- [ ] **Step 3: 스크린샷 커밋 + 푸시**
```bash
git add docs/screenshots
git commit -m "docs: polish round simulator screenshots"
git push
```
- [ ] **Step 4: 오너 보고**
시뮬레이터를 띄워둔 채로 오너에게 플레이 요청 — 특히 인트로 첫인상, 타일 광택, 맵의 "여정" 느낌, 클래식 모드 게임감 피드백 수집.
---
## 자체 검토 결과
- **Spec 커버리지**: 비주얼 시스템(T13), 주스(T56), 인트로(T710), 여정 맵(T1112), 엔드리스(T1314), 홈(T15), 검증(T16) — 스펙 8개 섹션 전부 매핑됨. "만들지 않는 것"(BGM/시네마틱/부스터/데일리)은 어느 태스크에도 없음 확인.
- **플레이스홀더**: 없음. 모든 코드 블록 완결.
- **타입 일관성**: `paintGlossyTile(canvas, rect, color, {glow})` (T2 정의 → T2/T12 사용), `TutorialStep` 3값+null (T9 정의 → T10 사용), `StageConfig.endless({required int seed})` (T13 정의 → T15 사용), `endlessBestProvider` (T14 정의 → T14/T15 사용), `debugDisableLoopingAnimations` (T3 정의 → T3/T10 참조) 일치 확인.