Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 KiB
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() 안에 추가:
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 클래스를 다음으로 교체:
/// 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.dart의 build()에서 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.dart의 main()에 추가:
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.xml과 drawable-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)),
),
),
],
);
}
}
주의: Positioned는 Stack의 직계 자식이어야 하므로 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.dart — GameViewState에 required 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 → 튜토리얼 3스텝 작동, 스킵 작동
- 줄 클리어: 스파크 + 점수 팝업 + 콤보 배너, 콤보 4+에서 화면 흔들림
- 승리: 별 스태거 + 컨페티 → "다음 스테이지" → 맵 복귀 시 다음 노드 발광
- 여정 맵: 현재 노드로 자동 스크롤, 위로 스크롤 시 잠긴 노드들
- 클래식: 홈 → 즉시 시작, 이동 칩 없음, 게임오버 카드에 점수/베스트
- 패배(스테이지): 근접 실패 링 표시
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 커버리지: 비주얼 시스템(T1–3), 주스(T5–6), 인트로(T7–10), 여정 맵(T11–12), 엔드리스(T13–14), 홈(T15), 검증(T16) — 스펙 8개 섹션 전부 매핑됨. "만들지 않는 것"(BGM/시네마틱/부스터/데일리)은 어느 태스크에도 없음 확인.
- 플레이스홀더: 없음. 모든 코드 블록 완결.
- 타입 일관성:
paintGlossyTile(canvas, rect, color, {glow})(T2 정의 → T2/T12 사용),TutorialStep3값+null (T9 정의 → T10 사용),StageConfig.endless({required int seed})(T13 정의 → T15 사용),endlessBestProvider(T14 정의 → T14/T15 사용),debugDisableLoopingAnimations(T3 정의 → T3/T10 참조) 일치 확인.