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

91 KiB
Raw Blame History

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.dartHapticFeedback).

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() 안에 추가:

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.dartSeasonTheme 클래스를 다음으로 교체:

/// Visual identity of a season. Colors are int ARGB so this file stays
/// pure Dart (architecture guard forbids Flutter imports here).
class SeasonTheme {
  const SeasonTheme({
    this.tileSet = 'spring',
    this.background = '',
    this.backgroundGradient = defaultGradient,
    this.accentColor = 0xFFFF7EB3,
    this.particleType = 'petals',
    this.tilePalette,
    this.boardTint,
  });

  factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
        tileSet: (json['tileSet'] as String?) ?? 'spring',
        background: (json['background'] as String?) ?? '',
        backgroundGradient: json['backgroundGradient'] != null
            ? [for (final c in json['backgroundGradient'] as List) c as int]
            : defaultGradient,
        accentColor: (json['accentColor'] as int?) ?? 0xFFFF7EB3,
        particleType: (json['particleType'] as String?) ?? 'petals',
        tilePalette: json['tilePalette'] != null
            ? [for (final c in json['tilePalette'] as List) c as int]
            : null,
        boardTint: json['boardTint'] as int?,
      );

  /// Season 1 "First Bloom": deep navy dusk.
  static const defaultGradient = [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E];

  static const fallback = SeasonTheme();

  final String tileSet;
  final String background;

  /// Top-to-bottom screen gradient, int ARGB.
  final List<int> backgroundGradient;
  final int accentColor;

  /// petals | snow | leaves | none
  final String particleType;

  /// Optional 8-color tile override; null = built-in candy palette.
  final List<int>? tilePalette;

  /// Optional board background override.
  final int? boardTint;

  Map<String, dynamic> toJson() => {
        'tileSet': tileSet,
        'background': background,
        'backgroundGradient': backgroundGradient,
        'accentColor': accentColor,
        'particleType': particleType,
        if (tilePalette != null) 'tilePalette': tilePalette,
        if (boardTint != null) 'boardTint': boardTint,
      };
}
  • Step 4: 전체 관련 테스트 통과 확인
flutter test test/game/models/ test/architecture_test.dart test/data/content_repository_test.dart

Expected: PASS (기존 pack.json 파싱 포함).

  • Step 5: 커밋
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 작성

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 멤버는 그대로 둠 — 기본값으로 계속 쓰임):

/// Resolved per-season colors for the UI layer. Built from a SeasonTheme;
/// falls back to the GamePalette constants.
class ThemeColors {
  ThemeColors(SeasonTheme theme)
      : gradient = [for (final c in theme.backgroundGradient) Color(c)],
        accent = Color(theme.accentColor),
        particleType = theme.particleType,
        board = theme.boardTint != null
            ? Color(theme.boardTint!)
            : GamePalette.boardBackground,
        tiles = theme.tilePalette != null
            ? [for (final c in theme.tilePalette!) Color(c)]
            : GamePalette.tileColors;

  final List<Color> gradient;
  final Color accent;
  final String particleType;
  final Color board;
  final List<Color> tiles;

  Color tile(int colorId) => tiles[colorId % tiles.length];
}

상단에 import '../../game/models/season.dart'; 추가.

  • Step 3: providers.dart에 activeThemeProvider 추가
/// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) {
  final flow = ref.watch(seasonFlowProvider);
  return flow?.pack.theme ?? SeasonTheme.fallback;
});

(season.dart는 이미 import되어 있음.)

  • Step 4: board_painter.dart 글로시 적용

paint()의 셀 루프 부분을 교체 — filled 셀과 gem을 글로시로:

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에 글로우 추가 — 첫 줄에:

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의 루프를 교체:

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: 커밋
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:

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:

import 'dart:async';

import 'package:block_seasons/ui/widgets/season_background.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  // Looping ambience animations never settle under pumpAndSettle.
  debugDisableLoopingAnimations = true;
  await testMain();
}
  • Step 2: 실패 확인
flutter test test/ui/season_background_test.dart

Expected: FAIL — 파일/심볼 없음.

  • Step 3: season_background.dart 구현
import 'dart:math' as math;

import 'package:flutter/material.dart';

import '../../game/models/season.dart';
import '../theme/palette.dart';

/// Set true in tests (flutter_test_config.dart): looping ambience would make
/// pumpAndSettle spin forever.
bool debugDisableLoopingAnimations = false;

/// Full-screen season ambience: vertical gradient plus drifting particles
/// (petals for season 1). Pure procedural — no image assets required; an AI
/// illustration layer can be added on top later without touching callers.
class SeasonBackground extends StatefulWidget {
  const SeasonBackground({super.key, required this.theme});

  final SeasonTheme theme;

  @override
  State<SeasonBackground> createState() => _SeasonBackgroundState();
}

class _SeasonBackgroundState extends State<SeasonBackground>
    with SingleTickerProviderStateMixin {
  late final AnimationController _drift = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 18),
  );

  @override
  void initState() {
    super.initState();
    if (!debugDisableLoopingAnimations) _drift.repeat();
  }

  @override
  void dispose() {
    _drift.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final colors = ThemeColors(widget.theme);
    return RepaintBoundary(
      child: AnimatedBuilder(
        animation: _drift,
        builder: (context, _) => CustomPaint(
          size: Size.infinite,
          painter: _AmbiencePainter(colors: colors, t: _drift.value),
        ),
      ),
    );
  }
}

class _AmbiencePainter extends CustomPainter {
  const _AmbiencePainter({required this.colors, required this.t});

  final ThemeColors colors;
  final double t;

  static const _particles = 9;

  // Deterministic pseudo-random in [0, 1) from an index.
  static double _hash(int i, double salt) {
    final v = math.sin(i * 12.9898 + salt) * 43758.5453;
    return v - v.floorToDouble();
  }

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: colors.gradient,
          stops: const [0.0, 0.55, 1.0],
        ).createShader(rect),
    );

    if (colors.particleType == 'none') return;
    for (var i = 0; i < _particles; i++) {
      final speed = 0.5 + _hash(i, 1) * 0.6;
      final phase = _hash(i, 2);
      final fall = (t * speed + phase) % 1.15 - 0.075;
      final x = (_hash(i, 3) +
              0.05 * math.sin(t * 2 * math.pi + i * 1.7)) *
          size.width;
      final y = fall * size.height;
      final scale = 7 + _hash(i, 4) * 9;
      final angle = t * 2 * math.pi * (0.4 + _hash(i, 5)) + i;
      _paintParticle(canvas, Offset(x, y), scale, angle);
    }
  }

  void _paintParticle(Canvas canvas, Offset c, double s, double angle) {
    canvas.save();
    canvas.translate(c.dx, c.dy);
    canvas.rotate(angle);
    final paint = Paint();
    switch (colors.particleType) {
      case 'snow':
        paint.color = Colors.white.withValues(alpha: 0.35);
        canvas.drawCircle(Offset.zero, s * 0.4, paint);
      case 'leaves':
        paint.color = const Color(0xFFE8945A).withValues(alpha: 0.35);
        canvas.drawOval(
            Rect.fromCenter(center: Offset.zero, width: s, height: s * 0.55),
            paint);
      default: // petals
        paint.color = colors.accent.withValues(alpha: 0.30);
        canvas.drawOval(
            Rect.fromCenter(
                center: Offset(s * 0.18, 0), width: s, height: s * 0.62),
            paint);
        canvas.drawOval(
            Rect.fromCenter(
                center: Offset(-s * 0.18, 0), width: s, height: s * 0.62),
            paint);
    }
    canvas.restore();
  }

  @override
  bool shouldRepaint(_AmbiencePainter old) =>
      old.t != t || old.colors != colors;
}
  • Step 4: GameScreen에 배경 적용

lib/ui/screens/game_screen.dartbuild()에서 Scaffold(body: SafeArea(child: Stack(...))) 구조를 다음으로 변경:

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 추가:

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: 커밋
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.dartmain()에 추가:

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 필드 추가:

bool _tutorialDone = false;
int _endlessBest = 0;

bool get tutorialDone => _tutorialDone;
int get endlessBest => _endlessBest;

Future<void> markTutorialDone() {
  _tutorialDone = true;
  return _flush();
}

Future<void> recordEndlessScore(int score) {
  if (score > _endlessBest) _endlessBest = score;
  return _flush();
}

생성자의 json 파싱부 끝에 추가:

_tutorialDone =
    (json['flags'] as Map<String, dynamic>?)?['tutorialDone'] as bool? ??
        false;
_endlessBest =
    (json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;

_flush()의 jsonEncode 맵에 추가:

'flags': {'tutorialDone': _tutorialDone},
'endless': {'best': _endlessBest},
  • Step 4: 통과 확인 + 커밋
flutter test test/data/

Expected: PASS.

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 구현

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

import '../../game/engine/game_engine.dart';
import '../../game/engine/game_event.dart';
import '../../game/models/grid.dart';
import '../theme/palette.dart';
import 'board_geometry.dart';
import 'tile_painter.dart';

enum _FxType { spark, popup, combo, confetti, settle }

class _Fx {
  _Fx(this.type, this.start, {this.pos = Offset.zero, this.data});

  final _FxType type;
  final Duration start;
  final Offset pos;
  final Object? data;

  static const durations = {
    _FxType.spark: Duration(milliseconds: 600),
    _FxType.popup: Duration(milliseconds: 900),
    _FxType.combo: Duration(milliseconds: 1000),
    _FxType.confetti: Duration(milliseconds: 1800),
    _FxType.settle: Duration(milliseconds: 140),
  };

  double progress(Duration now) {
    final d = durations[type]!;
    final p = (now - start).inMicroseconds / d.inMicroseconds;
    return p.clamp(0.0, 1.0);
  }

  bool done(Duration now) => progress(now) >= 1;
}

/// Transient game-feel effects above the board: clear sparks, rising score
/// popups, combo banners, win confetti, placed-piece settle. The game screen
/// reports events; effects expire on their own and the ticker stops when the
/// list drains, so widget tests settle normally.
class EffectsOverlay extends StatefulWidget {
  const EffectsOverlay({super.key});

  @override
  State<EffectsOverlay> createState() => EffectsOverlayState();
}

class EffectsOverlayState extends State<EffectsOverlay>
    with SingleTickerProviderStateMixin {
  late final Ticker _ticker = createTicker(_tick);
  final List<_Fx> _fx = [];
  Duration _now = Duration.zero;

  void _tick(Duration elapsed) {
    setState(() {
      _now = elapsed;
      _fx.removeWhere((e) => e.done(elapsed));
      if (_fx.isEmpty) _ticker.stop();
    });
  }

  void _add(_Fx fx) {
    _fx.add(fx);
    if (!_ticker.isActive) _ticker.start();
  }

  /// [boardRect] is the board's rect in this overlay's coordinates.
  void onPlacement(PlacementResult placement, {required Rect boardRect}) {
    final geo = BoardGeometry(boardSize: boardRect.width);
    final origin = boardRect.topLeft;

    for (final event in placement.events) {
      if (event is PiecePlaced) {
        _add(_Fx(_FxType.settle, _now,
            pos: origin +
                Offset(event.x * geo.cellSize, event.y * geo.cellSize),
            data: (event.piece, geo.cellSize)));
      }
    }

    final cleared = <Offset>[];
    for (final y in placement.clearedRows) {
      for (var x = 0; x < GridState.size; x++) {
        cleared.add(origin + geo.cellRect(x, y).center);
      }
    }
    for (final x in placement.clearedCols) {
      for (var y = 0; y < GridState.size; y++) {
        cleared.add(origin + geo.cellRect(x, y).center);
      }
    }
    for (final c in cleared) {
      _add(_Fx(_FxType.spark, _now, pos: c, data: geo.cellSize));
    }

    if (placement.pointsGained > 0 && placement.linesCleared > 0) {
      final at = cleared.isEmpty
          ? boardRect.center
          : cleared[cleared.length ~/ 2];
      _add(_Fx(_FxType.popup, _now,
          pos: at, data: '+${placement.pointsGained}'));
    }

    if (placement.comboStreak >= 2) {
      _add(_Fx(_FxType.combo, _now,
          pos: boardRect.center, data: placement.comboStreak));
    }
  }

  void onWin(Size screenSize) {
    for (var i = 0; i < 36; i++) {
      _add(_Fx(_FxType.confetti, _now,
          pos: Offset(screenSize.width * _hash(i, 1), -12),
          data: i));
    }
  }

  static double _hash(int i, double salt) {
    final v = math.sin(i * 12.9898 + salt) * 43758.5453;
    return v - v.floorToDouble();
  }

  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      child: CustomPaint(
        size: Size.infinite,
        painter: _FxPainter(List.of(_fx), _now),
      ),
    );
  }
}

class _FxPainter extends CustomPainter {
  const _FxPainter(this.fx, this.now);

  final List<_Fx> fx;
  final Duration now;

  @override
  void paint(Canvas canvas, Size size) {
    for (final e in fx) {
      final t = e.progress(now);
      switch (e.type) {
        case _FxType.spark:
          _spark(canvas, e, t);
        case _FxType.popup:
          _popup(canvas, e, t);
        case _FxType.combo:
          _combo(canvas, e, t, size);
        case _FxType.confetti:
          _confetti(canvas, e, t, size);
        case _FxType.settle:
          _settle(canvas, e, t);
      }
    }
  }

  void _spark(Canvas canvas, _Fx e, double t) {
    final cell = e.data as double;
    for (var i = 0; i < 6; i++) {
      final angle = i * math.pi / 3 + EffectsOverlayState._hash(i, 7) * 0.8;
      final dist = cell * (0.3 + 1.2 * Curves.easeOut.transform(t));
      final pos = e.pos + Offset(math.cos(angle), math.sin(angle)) * dist;
      canvas.drawCircle(
        pos,
        cell * 0.09 * (1 - t),
        Paint()
          ..color = Colors.white.withValues(alpha: (1 - t) * 0.9),
      );
    }
  }

  void _popup(Canvas canvas, _Fx e, double t) {
    final rise = 44 * Curves.easeOut.transform(t);
    _text(canvas, e.data as String, e.pos - Offset(0, rise),
        fontSize: 22,
        color: Colors.white.withValues(alpha: 1 - t * t),
        outline: true);
  }

  void _combo(Canvas canvas, _Fx e, double t, Size size) {
    final streak = e.data as int;
    final color = streak >= 6
        ? const Color(0xFFB980FF)
        : streak >= 4
            ? const Color(0xFFFF8A4D)
            : const Color(0xFFFFD166);
    final scale = t < 0.25
        ? Curves.easeOutBack.transform(t / 0.25)
        : 1.0;
    final alpha = t > 0.7 ? (1 - t) / 0.3 : 1.0;
    canvas.save();
    canvas.translate(e.pos.dx, e.pos.dy - 30);
    canvas.scale(scale);
    _text(canvas, 'COMBO ×$streak', Offset.zero,
        fontSize: streak >= 4 ? 40 : 34,
        color: color.withValues(alpha: alpha),
        outline: true);
    canvas.restore();
  }

  void _confetti(Canvas canvas, _Fx e, double t, Size size) {
    final i = e.data as int;
    final colors = GamePalette.tileColors;
    final x = e.pos.dx +
        28 * math.sin(t * 4 * math.pi + i);
    final y = t * (size.height + 40);
    canvas.save();
    canvas.translate(x, y);
    canvas.rotate(t * 6 * math.pi * (EffectsOverlayState._hash(i, 3) - 0.5));
    canvas.drawRect(
      Rect.fromCenter(center: Offset.zero, width: 9, height: 5),
      Paint()
        ..color =
            colors[i % colors.length].withValues(alpha: 1 - t * 0.6),
    );
    canvas.restore();
  }

  void _settle(Canvas canvas, _Fx e, double t) {
    final (piece, cellSize) = e.data as (dynamic, double);
    final scale = 1.08 - 0.08 * Curves.easeOut.transform(t);
    final alpha = 0.35 * (1 - t);
    for (final (dx, dy) in piece.offsets) {
      final rect = Rect.fromLTWH(
        e.pos.dx + dx * cellSize,
        e.pos.dy + dy * cellSize,
        cellSize,
        cellSize,
      );
      final scaled = Rect.fromCenter(
        center: rect.center,
        width: rect.width * scale,
        height: rect.height * scale,
      ).deflate(cellSize * 0.05);
      canvas.drawRRect(
        RRect.fromRectAndRadius(scaled, Radius.circular(cellSize * 0.18)),
        Paint()..color = Colors.white.withValues(alpha: alpha),
      );
    }
  }

  void _text(Canvas canvas, String s, Offset center,
      {required double fontSize, required Color color, bool outline = false}) {
    final painter = TextPainter(
      text: TextSpan(
        text: s,
        style: TextStyle(
          fontSize: fontSize,
          fontWeight: FontWeight.w900,
          color: color,
          shadows: outline
              ? const [Shadow(blurRadius: 14, color: Colors.black87)]
              : null,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    painter.paint(
        canvas, center - Offset(painter.width / 2, painter.height / 2));
  }

  @override
  bool shouldRepaint(_FxPainter old) => true;
}

주의: _settle(dynamic, double) 레코드에서 piece는 Piece 타입이지만 dynamic으로 받아 캐스팅을 피함 — 구현 시 import '../../game/models/piece.dart';(Piece, double)로 정확히 타이핑할 것.

  • Step 2: board_widget.dart에서 콤보 텍스트 제거

build()Stack children에서 if (_flash.isAnimating && _comboStreak >= 2) Center(...) 블록 전체와 _comboStreak 필드, didUpdateWidget_comboStreak = placement.comboStreak; 라인을 삭제. (플래시 효과는 유지.)

  • Step 3: game_screen.dart 배선 — 이펙트 + 햅틱 + 흔들림

상태 필드 추가 (_GameScreenState):

final _effectsKey = GlobalKey<EffectsOverlayState>();
late final AnimationController _shake = AnimationController(
  vsync: this,
  duration: const Duration(milliseconds: 350),
);

클래스 선언을 with TickerProviderStateMixin으로 변경(ConsumerState<GameScreen> 뒤에), dispose 오버라이드에서 _shake.dispose().

_onSessionChange의 fxTick 분기 확장:

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 분기에 컨페티 추가:

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(...))) 부분):

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),
    ),
  ),
),

결과 오버레이 바로 앞에 이펙트 레이어 삽입 (드래그 오버레이 뒤, 결과 오버레이 앞):

Positioned.fill(child: EffectsOverlay(key: _effectsKey)),

import 추가:

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: 커밋
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에 추가:

"almostThere": "{percent}% complete!",
"@almostThere": {
  "placeholders": { "percent": { "type": "int" } }
}

app_ko.arb에 추가:

"almostThere": "{percent}% 달성!"

실행: flutter gen-l10n

  • Step 2: 별 스태거 위젯

_resultOverlay의 별 Row를 교체:

Row(
  mainAxisSize: MainAxisSize.min,
  children: [
    for (var i = 0; i < 3; i++)
      TweenAnimationBuilder<double>(
        tween: Tween(begin: 0, end: 1),
        duration: Duration(milliseconds: 400 + i * 250),
        curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
        builder: (context, v, child) =>
            Transform.scale(scale: v, child: child),
        child: Icon(
          Icons.star,
          size: 44,
          color: i < view.starsEarned ? Colors.amber : Colors.white24,
        ),
      ),
  ],
),
  • Step 3: 근접 실패 링

_resultOverlay(_, _) (lost) 분기에서 카드 Column에, 제목 아래 추가할 위젯 (분기 구조상 actions 위에 삽입되도록 Column 빌드부에 조건 추가):

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
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의 본문을:

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 구현
import 'package:flutter/material.dart';

import '../widgets/season_background.dart';
import 'home_screen.dart';

/// Logo-assembly splash: four glossy blocks fly in to form a 2x2 mark, the
/// wordmark fades in, then we hand off. SaveRepository is already opened in
/// main() so this doubles as perceived-zero loading time.
class SplashScreen extends StatefulWidget {
  const SplashScreen({super.key});

  /// Where the splash goes when finished; Task 8 swaps this to the season
  /// title card.
  static Widget Function() nextScreen = () => const HomeScreen();

  @override
  State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen>
    with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1900),
  )..addStatusListener((status) {
      if (status == AnimationStatus.completed && mounted) {
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (_) => SplashScreen.nextScreen()),
        );
      }
    });

  static const _blocks = [
    (Color(0xFFFF7EB3), Offset(-1.2, -0.4), Offset(-0.5, -0.5)),
    (Color(0xFFFFD166), Offset(1.2, -0.4), Offset(0.5, -0.5)),
    (Color(0xFF6FCDF5), Offset(-1.2, 0.6), Offset(-0.5, 0.5)),
    (Color(0xFF7EDB9C), Offset(1.2, 0.6), Offset(0.5, 0.5)),
  ];

  @override
  void initState() {
    super.initState();
    _c.forward();
  }

  @override
  void dispose() {
    _c.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const blockSize = 46.0;
    const gap = 3.0;
    return Scaffold(
      backgroundColor: const Color(0xFF0E1430),
      body: AnimatedBuilder(
        animation: _c,
        builder: (context, _) {
          final titleT = const Interval(0.60, 0.88, curve: Curves.easeOut)
              .transform(_c.value);
          return Stack(
            alignment: Alignment.center,
            children: [
              for (var i = 0; i < _blocks.length; i++)
                _block(i, blockSize, gap),
              Transform.translate(
                offset: Offset(0, 78 + 12 * (1 - titleT)),
                child: Opacity(
                  opacity: titleT,
                  child: const Text(
                    'BLOCK SEASONS',
                    style: TextStyle(
                      fontSize: 26,
                      fontWeight: FontWeight.w900,
                      letterSpacing: 4,
                      color: Colors.white,
                    ),
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _block(int i, double size, double gap) {
    final (color, from, to) = _blocks[i];
    final t = Interval(0.06 * i, 0.45 + 0.06 * i, curve: Curves.easeOutBack)
        .transform(_c.value);
    final unit = size / 2 + gap;
    final begin = Offset(from.dx * 160, from.dy * 280);
    final end = Offset(to.dx * unit * 2, to.dy * unit * 2) / 2 * 2;
    final pos = Offset.lerp(begin, Offset(to.dx * (size + gap) , to.dy * (size + gap)) / 1, t)!;
    return Transform.translate(
      offset: pos / 1,
      child: Container(
        width: size,
        height: size,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(11),
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: [
              Color.lerp(color, Colors.white, 0.28)!,
              color,
              Color.lerp(color, Colors.black, 0.22)!,
            ],
          ),
          boxShadow: [
            BoxShadow(
              color: color.withValues(alpha: 0.55),
              blurRadius: 18,
            ),
          ],
        ),
      ),
    );
  }
}

구현 시 _block의 좌표 산식은 단순화할 것: begin = Offset(from.dx*160, from.dy*280), end = Offset(to.dx*(size+gap), to.dy*(size+gap)), pos = Offset.lerp(begin, end, t)!. (위 코드의 /1, *2/2 잔재 제거.)

lib/app.dart: home: const HomeScreen()home: const SplashScreen(), import 교체.

  • Step 3: 네이티브 런치 색 통일 (#0E1430)

iOS — ios/Runner/Base.lproj/LaunchScreen.storyboard에서 <color key="backgroundColor" systemColor="systemBackgroundColor"/> 줄을:

<color key="backgroundColor" red="0.054901960784" green="0.078431372549" blue="0.188235294118" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>

Android — android/app/src/main/res/drawable/launch_background.xmldrawable-v21/launch_background.xml 둘 다 전체 내용을:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="#FF0E1430"/>
        </shape>
    </item>
</layer-list>
  • Step 4: 검증 + 커밋
flutter analyze && flutter test

Expected: PASS (widget_test 포함).

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:

"seasonLabel": "SEASON",
"seasonStages": "{count} stages",
"@seasonStages": {
  "placeholders": { "count": { "type": "int" } }
}

app_ko.arb:

"seasonLabel": "SEASON",
"seasonStages": "{count}개 스테이지"

flutter gen-l10n 실행.

  • Step 2: 실패하는 테스트

test/ui/season_title_screen_test.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:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
import '../widgets/season_background.dart';
import 'home_screen.dart';

/// Cold-start interstitial: "SEASON 1 · First Bloom". Tap anywhere or wait
/// ~1.6s. If content somehow fails to load we bail straight to home.
class SeasonTitleScreen extends ConsumerStatefulWidget {
  const SeasonTitleScreen({super.key});

  @override
  ConsumerState<SeasonTitleScreen> createState() =>
      _SeasonTitleScreenState();
}

class _SeasonTitleScreenState extends ConsumerState<SeasonTitleScreen> {
  Timer? _auto;
  bool _navigated = false;

  void _go() {
    if (_navigated || !mounted) return;
    _navigated = true;
    Navigator.of(context).pushReplacement(
      MaterialPageRoute(builder: (_) => const HomeScreen()),
    );
  }

  @override
  void dispose() {
    _auto?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final seasons = ref.watch(seasonsProvider);
    final l10n = AppLocalizations.of(context)!;
    return seasons.when(
      loading: () {
        _auto ??= Timer(const Duration(milliseconds: 2500), _go);
        return const Scaffold(backgroundColor: Color(0xFF0E1430), body: SizedBox());
      },
      error: (_, __) {
        WidgetsBinding.instance.addPostFrameCallback((_) => _go());
        return const Scaffold(backgroundColor: Color(0xFF0E1430), body: SizedBox());
      },
      data: (list) {
        _auto?.cancel();
        _auto = Timer(const Duration(milliseconds: 1600), _go);
        final pack = list.first;
        final locale = Localizations.localeOf(context).languageCode;
        final number =
            int.tryParse(pack.seasonId.split('_').last) ?? 1;
        return GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: _go,
          child: Scaffold(
            body: Stack(
              fit: StackFit.expand,
              children: [
                SeasonBackground(theme: pack.theme),
                SafeArea(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        '${l10n.seasonLabel} $number',
                        style: TextStyle(
                          letterSpacing: 6,
                          fontSize: 14,
                          color: Colors.white.withValues(alpha: 0.7),
                        ),
                      ),
                      const SizedBox(height: 10),
                      Text(
                        pack.titleFor(locale),
                        style: const TextStyle(
                          fontSize: 38,
                          fontWeight: FontWeight.w900,
                          color: Colors.white,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Text(
                        l10n.seasonStages(pack.stages.length),
                        style: TextStyle(
                          fontSize: 14,
                          color: Colors.white.withValues(alpha: 0.6),
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

splash_screen.dart: nextScreen = () => const SeasonTitleScreen(); 로 기본값 교체 (import 추가, HomeScreen import 제거 가능).

  • Step 4: 검증 + 커밋
flutter analyze && flutter test

Expected: PASS (widget_test의 여유 pump가 카드 1.6초를 흡수).

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:

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:

import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers.dart';

enum TutorialStep { dragPiece, clearLine, explainHud }

/// First-play guided tutorial. State null = inactive. Events arriving in the
/// wrong step are ignored, so engine wiring can fire them unconditionally.
class TutorialNotifier extends Notifier<TutorialStep?> {
  @override
  TutorialStep? build() => null;

  void start() {
    if (ref.read(saveRepositoryProvider).tutorialDone) return;
    state = TutorialStep.dragPiece;
  }

  void onPlaced() {
    if (state == TutorialStep.dragPiece) state = TutorialStep.clearLine;
  }

  void onLineCleared() {
    if (state == TutorialStep.clearLine) state = TutorialStep.explainHud;
  }

  Future<void> dismissHud() async {
    if (state != TutorialStep.explainHud) return;
    await _finish();
  }

  Future<void> skip() async {
    if (state == null) return;
    await _finish();
  }

  Future<void> _finish() async {
    state = null;
    await ref.read(saveRepositoryProvider).markTutorialDone();
  }
}

providers.dart에 추가:

final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
  TutorialNotifier.new,
);

import: import 'tutorial_notifier.dart'; + export 편의상 export 'tutorial_notifier.dart' show TutorialStep; 는 불필요 — 테스트가 직접 import.

  • Step 3: 통과 + 커밋
flutter test test/state/
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:

"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:

"skip": "건너뛰기",
"gotIt": "알겠어요!",
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!"

flutter gen-l10n.

  • Step 2: tutorial_overlay.dart 구현
import 'package:flutter/material.dart';

import '../../l10n/gen/app_localizations.dart';
import '../../state/tutorial_notifier.dart';
import 'season_background.dart' show debugDisableLoopingAnimations;

/// Non-blocking guidance overlay: dim veil, message bubble, animated hand on
/// the drag step, skip always available. Input still reaches the game so the
/// player advances by actually doing the action.
class TutorialOverlay extends StatefulWidget {
  const TutorialOverlay({
    super.key,
    required this.step,
    required this.handFrom,
    required this.handTo,
    required this.onSkip,
    required this.onDismissHud,
  });

  final TutorialStep step;

  /// Global → local-to-overlay coordinates of the hand animation path
  /// (tray slot 0 → suggested board anchor). Only used on dragPiece.
  final Offset handFrom;
  final Offset handTo;
  final VoidCallback onSkip;
  final VoidCallback onDismissHud;

  @override
  State<TutorialOverlay> createState() => _TutorialOverlayState();
}

class _TutorialOverlayState extends State<TutorialOverlay>
    with SingleTickerProviderStateMixin {
  late final AnimationController _hand = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1400),
  );

  @override
  void initState() {
    super.initState();
    if (!debugDisableLoopingAnimations) _hand.repeat();
  }

  @override
  void dispose() {
    _hand.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final message = switch (widget.step) {
      TutorialStep.dragPiece => l10n.tutorialDrag,
      TutorialStep.clearLine => l10n.tutorialClear,
      TutorialStep.explainHud => l10n.tutorialHud,
    };

    return Stack(
      children: [
        // Veil that lets touches through.
        IgnorePointer(
          child: Container(color: Colors.black.withValues(alpha: 0.25)),
        ),
        if (widget.step == TutorialStep.dragPiece)
          IgnorePointer(
            child: AnimatedBuilder(
              animation: _hand,
              builder: (context, _) {
                final t = Curves.easeInOut.transform(_hand.value);
                final pos = Offset.lerp(widget.handFrom, widget.handTo, t)!;
                return Positioned(
                  left: pos.dx,
                  top: pos.dy,
                  child: Opacity(
                    opacity: _hand.value < 0.9 ? 1 : (1 - _hand.value) * 10,
                    child: const Icon(Icons.touch_app,
                        size: 44, color: Colors.white),
                  ),
                );
              },
            ),
          ),
        Positioned(
          top: 70,
          left: 24,
          right: 24,
          child: Card(
            color: const Color(0xEE1C2340),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(16),
            ),
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    message,
                    textAlign: TextAlign.center,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.w700,
                      color: Colors.white,
                    ),
                  ),
                  if (widget.step == TutorialStep.explainHud) ...[
                    const SizedBox(height: 10),
                    FilledButton(
                      onPressed: widget.onDismissHud,
                      child: Text(l10n.gotIt),
                    ),
                  ],
                ],
              ),
            ),
          ),
        ),
        Positioned(
          top: 8,
          right: 8,
          child: TextButton(
            onPressed: widget.onSkip,
            child: Text(l10n.skip,
                style: const TextStyle(color: Colors.white54)),
          ),
        ),
      ],
    );
  }
}

주의: PositionedStack의 직계 자식이어야 하므로 hand의 AnimatedBuilder 구조를 구현 시 조정 — Positioned.fill 안에 CustomPaint로 손 아이콘을 그리거나, AnimatedBuilder를 바깥으로 빼고 Positioned(left: pos.dx, ...)가 직접 Stack 자식이 되게 할 것. 권장 구조:

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에:

bool _tutorialStartChecked = false;

build 시작부(view null 체크 뒤)에:

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 분기 안(이펙트 호출 근처)에 추가:

ref.read(tutorialProvider.notifier).onPlaced();
if (placement.linesCleared > 0) {
  ref.read(tutorialProvider.notifier).onLineCleared();
}

Stack children 끝(닫기 버튼 앞)에:

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(),
    ),
  ),

헬퍼 (클래스 안):

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 → 튜토리얼 미발동, 영향 없음.

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:

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:

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
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')로 찾도록 변경:

// 기존 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 전면 재작성
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.

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:

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에 필드/팩토리 추가:

// 생성자 파라미터에 추가:
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 구현

세 군데 수정:

// 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
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:

"gameOver": "Game Over",
"bestScore": "Best {score}",
"@bestScore": { "placeholders": { "score": { "type": "int" } } },
"newBest": "NEW BEST!"

app_ko.arb:

"gameOver": "게임 오버",
"bestScore": "최고 {score}",
"newBest": "신기록!"

flutter gen-l10n.

  • Step 2: 실패하는 테스트 (베스트 노티파이어)

test/state/endless_best_test.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:

import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'providers.dart';

/// Reactive view over SaveRepository's endless best score.
class EndlessBestNotifier extends Notifier<int> {
  @override
  int build() => ref.read(saveRepositoryProvider).endlessBest;

  /// Records the run; returns true when it set a new best.
  Future<bool> record(int score) async {
    final repo = ref.read(saveRepositoryProvider);
    final isNewBest = score > state;
    await repo.recordEndlessScore(score);
    state = repo.endlessBest;
    return isNewBest;
  }
}

providers.dart:

final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
  EndlessBestNotifier.new,
);

(+ import 'endless_best_notifier.dart';)

game_session_notifier.dartGameViewStaterequired this.endless / final bool endless 추가, _publish에서 endless: engine.endless 전달 (엔진 getter는 Task 13에서 추가됨). 이 클래스를 생성하는 기존 테스트가 있으면 같이 갱신.

hud_widget.dart — 이동 칩 분기:

// Row의 첫 자식:
view.endless ? const SizedBox(width: 48) : _movesChip(theme),

game_screen.dart:

(a) 상태 필드 bool _endlessNewBest = false;

(b) _onSessionChange의 phase 전환부에 추가:

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 분기를 (_, _) 보다 먼저 추가:

(GamePhase.lost, _) when view.endless => (
    l10n.gameOver,
    [
      FilledButton(
        onPressed: () {
          setState(() => _endlessNewBest = false);
          notifier.restart();
        },
        child: Text(l10n.playAgain),
      ),
    ],
  ),

그리고 카드 Column에 (별/근접실패 링과 같은 위치 패턴으로):

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
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)
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 재작성
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.

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 시뮬레이터 빌드 & 실행
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. 패배(스테이지): 근접 실패 링 표시
xcrun simctl io booted screenshot docs/screenshots/sim_polish_home.png
xcrun simctl io booted screenshot docs/screenshots/sim_polish_map.png

(각 화면을 띄운 시점에 촬영.)

  • Step 3: 스크린샷 커밋 + 푸시
git add docs/screenshots
git commit -m "docs: polish round simulator screenshots"
git push
  • Step 4: 오너 보고

시뮬레이터를 띄워둔 채로 오너에게 플레이 요청 — 특히 인트로 첫인상, 타일 광택, 맵의 "여정" 느낌, 클래식 모드 게임감 피드백 수집.


자체 검토 결과

  • Spec 커버리지: 비주얼 시스템(T13), 주스(T56), 인트로(T710), 여정 맵(T1112), 엔드리스(T1314), 홈(T15), 검증(T16) — 스펙 8개 섹션 전부 매핑됨. "만들지 않는 것"(BGM/시네마틱/부스터/데일리)은 어느 태스크에도 없음 확인.
  • 플레이스홀더: 없음. 모든 코드 블록 완결.
  • 타입 일관성: paintGlossyTile(canvas, rect, color, {glow}) (T2 정의 → T2/T12 사용), TutorialStep 3값+null (T9 정의 → T10 사용), StageConfig.endless({required int seed}) (T13 정의 → T15 사용), endlessBestProvider (T14 정의 → T14/T15 사용), debugDisableLoopingAnimations (T3 정의 → T3/T10 참조) 일치 확인.