# 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 참조) 일치 확인.