diff --git a/docs/store/phase7-submission-guide.md b/docs/store/phase7-submission-guide.md index d4961d1..d9f7941 100644 --- a/docs/store/phase7-submission-guide.md +++ b/docs/store/phase7-submission-guide.md @@ -26,7 +26,10 @@ - 개인정보처리방침 페이지: `docs/store/privacy-policy.html` - app-ads.txt: `docs/store/app-ads.txt` - 스토어 카피 EN/KO: `docs/store/store-listing.md` -- ⬜ **스크린샷**: 아직 없음 → Claude에게 "스크린샷 뽑아줘" 요청하면 시뮬레이터로 생성 +- ✅ **스크린샷** (각 3장: 홈·플레이·점수전): + - iOS 6.7"(1290×2796): `docs/store/screenshots/ios/` + - Android 폰(1080×1920): `docs/store/screenshots/android/` + - (시즌 여정 맵 컷은 추후 추가 예정 — 현재 3장으로 제출 충분) --- @@ -95,7 +98,7 @@ - **자세한 설명** (≤4000): store-listing.md의 KO(또는 EN) 본문 - **앱 아이콘** (512×512): `docs/store/play_icon_512.png` - **그래픽 이미지** (1024×500): `docs/store/feature_graphic.png` -- **휴대전화 스크린샷** (최소 2장): ⬜ Claude에게 요청해 받기 +- **휴대전화 스크린샷** (최소 2장): `docs/store/screenshots/android/` (3장) - **카테고리**: 게임 → 퍼즐 · **태그**: 퍼즐/캐주얼 - **개인정보처리방침 URL**: 0-2에서 올린 주소 - **연락처 이메일**: `airkjw@gmail.com` @@ -129,7 +132,7 @@ - 부제(≤30): KO `시즌마다 새로워지는 블록 퍼즐` - 프로모션 텍스트·설명·키워드: `docs/store/store-listing.md`에서 복붙 - **개인정보처리방침 URL**: 0-2 주소 -- **스크린샷** (6.7" 및 6.5" 필수): ⬜ Claude에게 요청 +- **스크린샷**: `docs/store/screenshots/ios/` (1290×2796=6.7", 3장). 6.5"는 같은 컷 재업로드 또는 생략 가능 ### B-3. 앱 개인정보 (App Privacy) — 아래 값 그대로 | 데이터 | 수집 | 목적 | 사용자 연결 | 추적 | @@ -154,7 +157,7 @@ # 권장 진행 순서 (최단 경로) 1. **0-1 키스토어 백업** (5분, 지금) 2. **0-2 개인정보처리방침 + app-ads.txt 호스팅** (양 스토어 리스팅의 필수 입력값이라 먼저) -3. **Claude에게 스크린샷 요청** (리스팅에 필요) +3. **스크린샷 준비됨** ✅ (`docs/store/screenshots/`) — 리스팅에 바로 업로드 4. **Apple B-1 빌드 업로드 시작** (처리에 시간 걸리니 먼저 걸어두기) 5. Play A-1~A-7 + Apple B-2~B-4 문항 채우기 (대부분 위 표 복붙) 6. Apple B-5 유료앱 계약 → 양쪽 **심사 제출** diff --git a/docs/store/screenshots/android/01_home.png b/docs/store/screenshots/android/01_home.png new file mode 100644 index 0000000..107159f Binary files /dev/null and b/docs/store/screenshots/android/01_home.png differ diff --git a/docs/store/screenshots/android/02_play.png b/docs/store/screenshots/android/02_play.png new file mode 100644 index 0000000..69f1247 Binary files /dev/null and b/docs/store/screenshots/android/02_play.png differ diff --git a/docs/store/screenshots/android/03_levels.png b/docs/store/screenshots/android/03_levels.png new file mode 100644 index 0000000..74f1ebc Binary files /dev/null and b/docs/store/screenshots/android/03_levels.png differ diff --git a/docs/store/screenshots/ios/01_home.png b/docs/store/screenshots/ios/01_home.png new file mode 100644 index 0000000..4651292 Binary files /dev/null and b/docs/store/screenshots/ios/01_home.png differ diff --git a/docs/store/screenshots/ios/02_play.png b/docs/store/screenshots/ios/02_play.png new file mode 100644 index 0000000..c1ac504 Binary files /dev/null and b/docs/store/screenshots/ios/02_play.png differ diff --git a/docs/store/screenshots/ios/03_levels.png b/docs/store/screenshots/ios/03_levels.png new file mode 100644 index 0000000..331fcab Binary files /dev/null and b/docs/store/screenshots/ios/03_levels.png differ diff --git a/test/tool/generate_store_screenshots_test.dart b/test/tool/generate_store_screenshots_test.dart new file mode 100644 index 0000000..12fbe9b --- /dev/null +++ b/test/tool/generate_store_screenshots_test.dart @@ -0,0 +1,253 @@ +// Headless store-screenshot generator. Renders the app's key screens to PNGs +// at both store device sizes, in English, with a real font loaded (the +// flutter_test default font draws every glyph as a box). +// +// flutter test test/tool/generate_store_screenshots_test.dart +// +// Output: docs/store/screenshots/{ios,android}/*.png +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:block_seasons/core/rng.dart'; +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/game/engine/piece_generator.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/game_screen.dart'; +import 'package:block_seasons/ui/screens/home_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +typedef _Device = ({String dir, int pw, int ph, double dpr}); + +const _devices = <_Device>[ + (dir: 'ios', pw: 1290, ph: 2796, dpr: 3.0), // iPhone 6.7" + (dir: 'android', pw: 1080, ph: 1920, dpr: 3.0), // Play phone +]; + +// A lively mid-game board: three gems to clear plus scattered colored blocks. +final _playStage = StageConfig.fromJson({ + 'id': 'shot_play', + 'seed': 777, + 'moveLimit': 20, + 'preset': [ + {'x': 3, 'y': 1, 't': 'gem'}, + {'x': 5, 'y': 3, 't': 'gem'}, + {'x': 2, 'y': 5, 't': 'gem'}, + {'x': 0, 'y': 7, 't': 'filled', 'c': 0}, + {'x': 1, 'y': 7, 't': 'filled', 'c': 3}, + {'x': 2, 'y': 7, 't': 'filled', 'c': 5}, + {'x': 3, 'y': 7, 't': 'filled', 'c': 2}, + {'x': 5, 'y': 7, 't': 'filled', 'c': 6}, + {'x': 6, 'y': 7, 't': 'filled', 'c': 1}, + {'x': 7, 'y': 7, 't': 'filled', 'c': 4}, + {'x': 0, 'y': 6, 't': 'filled', 'c': 3}, + {'x': 1, 'y': 6, 't': 'filled', 'c': 0}, + {'x': 6, 'y': 6, 't': 'filled', 'c': 4}, + {'x': 7, 'y': 6, 't': 'filled', 'c': 7}, + {'x': 0, 'y': 5, 't': 'filled', 'c': 5}, + {'x': 7, 'y': 5, 't': 'filled', 'c': 2}, + {'x': 5, 'y': 0, 't': 'filled', 'c': 3}, + {'x': 2, 'y': 2, 't': 'filled', 'c': 7}, + ], + 'objectives': [ + {'type': 'clearGems', 'count': 3}, + ], + 'stars': { + 'two': {'movesLeft': 5}, + 'three': {'movesLeft': 10}, + }, + 'generatorProfile': 'mid', +}); + +// A denser, higher-tension board for the third shot (score-chase objective so +// it stays mid-play rather than completing). +final _denseStage = StageConfig.fromJson({ + 'id': 'shot_dense', + 'seed': 321, + 'moveLimit': 16, + 'preset': [ + {'x': 2, 'y': 1, 't': 'gem'}, + {'x': 6, 'y': 2, 't': 'gem'}, + {'x': 0, 'y': 3, 't': 'filled', 'c': 6}, + {'x': 1, 'y': 3, 't': 'filled', 'c': 6}, + {'x': 7, 'y': 3, 't': 'filled', 'c': 2}, + {'x': 0, 'y': 4, 't': 'filled', 'c': 1}, + {'x': 3, 'y': 4, 't': 'filled', 'c': 4}, + {'x': 4, 'y': 4, 't': 'filled', 'c': 4}, + {'x': 7, 'y': 4, 't': 'filled', 'c': 0}, + {'x': 0, 'y': 5, 't': 'filled', 'c': 3}, + {'x': 1, 'y': 5, 't': 'filled', 'c': 3}, + {'x': 2, 'y': 5, 't': 'filled', 'c': 5}, + {'x': 5, 'y': 5, 't': 'filled', 'c': 7}, + {'x': 6, 'y': 5, 't': 'filled', 'c': 1}, + {'x': 7, 'y': 5, 't': 'filled', 'c': 1}, + {'x': 0, 'y': 6, 't': 'filled', 'c': 2}, + {'x': 1, 'y': 6, 't': 'filled', 'c': 0}, + {'x': 2, 'y': 6, 't': 'filled', 'c': 0}, + {'x': 3, 'y': 6, 't': 'filled', 'c': 5}, + {'x': 5, 'y': 6, 't': 'filled', 'c': 6}, + {'x': 6, 'y': 6, 't': 'filled', 'c': 3}, + {'x': 0, 'y': 7, 't': 'filled', 'c': 4}, + {'x': 1, 'y': 7, 't': 'filled', 'c': 4}, + {'x': 2, 'y': 7, 't': 'filled', 'c': 2}, + {'x': 4, 'y': 7, 't': 'filled', 'c': 7}, + {'x': 5, 'y': 7, 't': 'filled', 'c': 7}, + {'x': 6, 'y': 7, 't': 'filled', 'c': 5}, + {'x': 7, 'y': 7, 't': 'filled', 'c': 0}, + ], + 'objectives': [ + {'type': 'reachScore', 'target': 6000}, + ], + 'stars': { + 'two': {'movesLeft': 4}, + 'three': {'movesLeft': 8}, + }, + 'generatorProfile': 'hard', +}); + +Future _loadFont() async { + final bytes = + File('/System/Library/Fonts/Supplemental/Arial.ttf').readAsBytesSync(); + final loader = FontLoader('Arial') + ..addFont(Future.value(ByteData.view(bytes.buffer))); + await loader.load(); +} + +Widget _wrap(ProviderContainer c, Widget screen, GlobalKey key) => + UncontrolledProviderScope( + container: c, + child: RepaintBoundary( + key: key, + child: MaterialApp( + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + fontFamily: 'Arial', + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF5B7FFF), + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + home: screen, + ), + ), + ); + +// Captures the boundary layer to a PNG at [dpr]x — the same OffsetLayer.toImage +// call the golden matcher makes, but scaled up for store-resolution output. +Future _shoot( + WidgetTester tester, GlobalKey key, String dir, String file, double dpr) async { + var ro = key.currentContext!.findRenderObject()!; + while (!ro.isRepaintBoundary) { + ro = ro.parent!; + } + final layer = ro.debugLayer! as OffsetLayer; + final bounds = ro.paintBounds; + // Real-async raster work must run inside runAsync, or it corrupts the + // binding's frame pipeline for the next pump. + final bytes = await tester.runAsync(() async { + final image = await layer.toImage(bounds, pixelRatio: dpr); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data; + }); + final f = File('docs/store/screenshots/$dir/$file') + ..parent.createSync(recursive: true); + f.writeAsBytesSync(bytes!.buffer.asUint8List()); +} + +/// The ambient season background animates forever, so pumpAndSettle would +/// hang. Pump a couple of fixed frames to let layout + intro fades land. +Future _steady(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 450)); +} + +void main() { + testWidgets('generate store screenshots', (tester) async { + await _loadFont(); + + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + await repo.recordEndlessScore(12480); + await repo.markTutorialDone(); + final pack = SeasonPack.fromJson(jsonDecode( + File('assets/seasons/season_001/pack.json').readAsStringSync()) + as Map); + for (var i = 1; i <= 5; i++) { + await repo.recordResult( + seasonId: 'season_001', + stageId: pack.stages[i - 1].id, + stars: i.isEven ? 2 : 3, + score: 1000 + i * 220, + ); + } + + for (final d in _devices) { + tester.view.devicePixelRatio = d.dpr; + tester.view.physicalSize = Size(d.pw.toDouble(), d.ph.toDouble()); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // 1) Home + { + final key = GlobalKey(); + final c = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)]); + await tester.pumpWidget(_wrap(c, const HomeScreen(), key)); + await _steady(tester); + await _shoot(tester, key, d.dir, '01_home.png', d.dpr); + c.dispose(); + } + + // 3) Gameplay + { + final key = GlobalKey(); + final c = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)]); + c.read(gameSessionProvider.notifier).startStage( + _playStage, + generator: PieceGenerator(SeededRng(7)), + ); + await tester.pumpWidget(_wrap(c, const GameScreen(), key)); + await _steady(tester); + await _shoot(tester, key, d.dir, '02_play.png', d.dpr); + c.dispose(); + } + + // 3b) A denser board (score chase) + { + final key = GlobalKey(); + final c = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)]); + c.read(gameSessionProvider.notifier).startStage( + _denseStage, + generator: PieceGenerator(SeededRng(13)), + ); + await tester.pumpWidget(_wrap(c, const GameScreen(), key)); + await _steady(tester); + await _shoot(tester, key, d.dir, '03_levels.png', d.dpr); + c.dispose(); + } + } + + for (final d in _devices) { + for (final f in ['01_home', '02_play', '03_levels']) { + expect(File('docs/store/screenshots/${d.dir}/$f.png').existsSync(), + isTrue, + reason: '${d.dir}/$f'); + } + } + }); +}