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