From a69120e46b05bb252efa96b5be0527ef20bb2c02 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 20:52:26 +0900 Subject: [PATCH] docs: add Phase 3.5 commercial polish implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-06-11-commercial-polish.md | 3097 +++++++++++++++++ 1 file changed, 3097 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-commercial-polish.md diff --git a/docs/superpowers/plans/2026-06-11-commercial-polish.md b/docs/superpowers/plans/2026-06-11-commercial-polish.md new file mode 100644 index 0000000..86e73bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-commercial-polish.md @@ -0,0 +1,3097 @@ +# 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 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 backgroundGradient; + final int accentColor; + + /// petals | snow | leaves | none + final String particleType; + + /// Optional 8-color tile override; null = built-in candy palette. + final List? tilePalette; + + /// Optional board background override. + final int? boardTint; + + Map 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 gradient; + final Color accent; + final String particleType; + final Color board; + final List 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((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 testExecutable(FutureOr 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 createState() => _SeasonBackgroundState(); +} + +class _SeasonBackgroundState extends State + 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 markTutorialDone() { + _tutorialDone = true; + return _flush(); +} + +Future recordEndlessScore(int score) { + if (score > _endlessBest) _endlessBest = score; + return _flush(); +} +``` + +생성자의 json 파싱부 끝에 추가: + +```dart +_tutorialDone = + (json['flags'] as Map?)?['tutorialDone'] as bool? ?? + false; +_endlessBest = + (json['endless'] as Map?)?['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 createState() => EffectsOverlayState(); +} + +class EffectsOverlayState extends State + 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 = []; + 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(); +late final AnimationController _shake = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), +); +``` + +클래스 선언을 `with TickerProviderStateMixin`으로 변경(`ConsumerState` 뒤에), `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( + 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 createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + 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`에서 +`` 줄을: + +```xml + +``` + +Android — `android/app/src/main/res/drawable/launch_background.xml`과 `drawable-v21/launch_background.xml` 둘 다 전체 내용을: + +```xml + + + + + + + + +``` + +- [ ] **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 createState() => + _SeasonTitleScreenState(); +} + +class _SeasonTitleScreenState extends ConsumerState { + 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 { + @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 dismissHud() async { + if (state != TutorialStep.explainHud) return; + await _finish(); + } + + Future skip() async { + if (state == null) return; + await _finish(); + } + + Future _finish() async { + state = null; + await ref.read(saveRepositoryProvider).markTutorialDone(); + } +} +``` + +`providers.dart`에 추가: + +```dart +final tutorialProvider = NotifierProvider( + 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 createState() => _TutorialOverlayState(); +} + +class _TutorialOverlayState extends State + 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 { + @override + int build() => ref.read(saveRepositoryProvider).endlessBest; + + /// Records the run; returns true when it set a new best. + Future 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.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 커버리지**: 비주얼 시스템(T1–3), 주스(T5–6), 인트로(T7–10), 여정 맵(T11–12), 엔드리스(T13–14), 홈(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 참조) 일치 확인.