This commit is contained in:
2025-11-19 17:00:33 +09:00
parent 2008c377f4
commit 09665fa073
442 changed files with 18389 additions and 805 deletions
@@ -0,0 +1,228 @@
// packages/feature_game_cardflip/lib/screens/cardflip_game_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:feature_common/feature_common.dart';
import '../controllers/cardflip_controller.dart';
import '../models/cardflip_models.dart';
class CardFlipGameScreen extends StatefulWidget {
const CardFlipGameScreen({super.key});
@override
State<CardFlipGameScreen> createState() => _CardFlipGameScreenState();
}
class _CardFlipGameScreenState extends State<CardFlipGameScreen> {
bool _isDialogShowing = false;
// 아이콘 풀
static const List<IconData> _iconPool = [
Icons.home, Icons.favorite, Icons.star, Icons.person, Icons.settings,
Icons.lock, Icons.map, Icons.camera_alt, Icons.phone, Icons.music_note,
Icons.flight, Icons.directions_car, Icons.shopping_cart, Icons.visibility,
Icons.delete, Icons.edit, Icons.share, Icons.wifi, Icons.battery_full,
Icons.bluetooth, Icons.lightbulb, Icons.wb_sunny, Icons.ac_unit, Icons.access_alarm,
Icons.android, Icons.apple, Icons.attach_file, Icons.audiotrack, Icons.beach_access,
Icons.cake, Icons.local_pizza, Icons.local_cafe, Icons.local_florist, Icons.local_shipping
];
@override
void initState() {
super.initState();
// [🔥 신규] 게임 시작 전 가이드 표시
WidgetsBinding.instance.addPostFrameCallback((_) {
_showGameGuide();
});
}
// 🔽 [🔥 신규] 게임 가이드 다이얼로그
void _showGameGuide() {
final controller = context.read<CardFlipController>();
final difficulty = controller.difficulty;
String title = "게임 방법";
String message = "두 장의 카드를 뒤집어\n똑같은 그림을 찾으세요.";
if (difficulty.contentType == CardContentType.calculation) {
title = "연산 매칭";
message = "카드에 적힌 '계산식'과\n그 '정답'을 짝지어주세요.\n\n예: [2 + 3] ↔ [5]";
} else if (difficulty.contentType == CardContentType.pairWord) {
title = "연상 매칭";
message = "서로 관련있는 '짝꿍 단어'를\n찾아주세요.\n\n예: [토끼] ↔ [당근]";
}
showDialog(
context: context,
barrierDismissible: false, // 반드시 확인을 눌러야 함
builder: (context) => AlertDialog(
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
content: Text(message, style: const TextStyle(fontSize: 16), textAlign: TextAlign.center),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// [🔥 핵심] 가이드 닫으면 타이머 시작
controller.startGameTimer();
},
child: const Text("시작하기"),
),
],
),
);
}
void _showGameCompletion(CardFlipController controller) async {
String formatScore(int primary, int? secondary) {
return '남은 시간: ${primary}초 (시도: $secondary회)';
}
Future<void> saveProgress(String playerName) async {
if (controller.isTimeOut) return;
final identityService = IdentityService();
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'CARD_FLIP');
if (currentMaxLevel < 99) {
if (controller.difficulty.levelIndex >= currentMaxLevel) {
int nextLevel = controller.difficulty.levelIndex + 1;
if (nextLevel > CardFlipDifficulties.allDifficulties.length) {
await identityService.saveMaxUnlockedLevel(99, gameType: 'CARD_FLIP');
} else {
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'CARD_FLIP');
}
}
}
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameCompletionScreen(
args: GameResultArgs(
gameType: 'CARD_FLIP',
contextId: controller.difficulty.contextId,
primaryScore: controller.remainingTime,
secondaryScore: controller.flipCount,
userId: controller.userId,
userName: controller.userName,
scoreFormatter: formatScore,
onProgressSave: saveProgress,
),
),
),
);
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final controller = context.watch<CardFlipController>();
final theme = Theme.of(context);
if (controller.isGameCompleted && !_isDialogShowing) {
_isDialogShowing = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showGameCompletion(controller);
});
}
final Color timeColor = controller.remainingTime <= 10 ? theme.colorScheme.error : theme.colorScheme.onSurface;
return Scaffold(
appBar: AppBar(
title: Text(controller.difficulty.name),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: Text(
'${controller.remainingTime}s',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: timeColor),
),
),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text("뒤집은 횟수: ${controller.flipCount}", style: TextStyle(fontSize: 16, color: theme.textTheme.bodyMedium?.color)),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = controller.difficulty.cols;
return GridView.builder(
itemCount: controller.cards.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
return _buildCard(controller.cards[index], controller, theme);
},
);
},
),
),
),
],
),
);
}
Widget _buildCard(CardItem card, CardFlipController controller, ThemeData theme) {
return GestureDetector(
onTap: () => controller.onCardTapped(card),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
decoration: BoxDecoration(
color: card.isFaceUp || card.isMatched
? Colors.white
: theme.primaryColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black12),
boxShadow: [
BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(2, 2))
],
),
child: Center(
child: (card.isFaceUp || card.isMatched)
? _buildCardContent(card)
: Icon(Icons.help_outline, color: Colors.white.withOpacity(0.5), size: 32),
),
),
);
}
// 🔽 [🔥 수정] displayContent 사용
Widget _buildCardContent(CardItem card) {
if (card.displayContent.startsWith("ICON_")) {
final int iconIndex = int.tryParse(card.displayContent.split('_')[1]) ?? 0;
final IconData icon = _iconPool[iconIndex % _iconPool.length];
return Icon(icon, size: 40, color: Colors.orange);
} else {
return Padding(
padding: const EdgeInsets.all(4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
card.displayContent, // 👈 displayContent 표시
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
),
);
}
}
}
@@ -0,0 +1,229 @@
// packages/feature_game_cardflip/lib/screens/cardflip_lobby_screen.dart
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:feature_common/feature_common.dart';
import 'cardflip_game_screen.dart';
import '../models/cardflip_models.dart';
import '../controllers/cardflip_controller.dart';
class CardFlipLobbyScreen extends StatefulWidget {
const CardFlipLobbyScreen({super.key});
@override
State<CardFlipLobbyScreen> createState() => _CardFlipLobbyScreenState();
}
class _CardFlipLobbyScreenState extends State<CardFlipLobbyScreen> {
int _maxUnlockedLevel = 1;
Map<int, (int, int)> _rankHistory = {};
bool _isLoading = false;
late final SessionNotifier _sessionNotifier;
late final LobbyHelperService _lobbyHelper;
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
@override
void initState() {
super.initState();
_sessionNotifier = context.read<SessionNotifier>();
// 헬퍼 서비스 초기화
_lobbyHelper = LobbyHelperService(
identityService: _identityService,
puzzleService: _puzzleService,
);
_loadProgress(forceRefreshRanks: true);
}
/// [공통 로직 사용] 레벨 잠금 상태 및 랭킹 이력 로드
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
// 1. 최대 레벨 로드
final maxLevel = await _lobbyHelper.loadMaxLevel('CARD_FLIP');
if (mounted) {
setState(() {
_maxUnlockedLevel = maxLevel;
});
}
// 2. 랭킹 이력 로드 (필요한 경우만)
if (!forceRefreshRanks) return;
final String? myName = _sessionNotifier.session?.userName;
if (myName == null) return;
try {
final rankHistory = await _lobbyHelper.loadRankHistory<CardFlipDifficulty>(
gameType: 'CARD_FLIP',
myName: myName,
allLevels: CardFlipDifficulties.allDifficulties,
getLevelIndex: (level) => level.levelIndex,
);
if (mounted) {
setState(() {
_rankHistory = rankHistory;
});
}
} catch (e) {
log("CardFlipLobbyScreen: 랭킹 확인 실패: $e");
}
}
/// [게임 시작] 컨트롤러 생성 및 화면 이동
Future<void> _startGame(CardFlipDifficulty level) async {
setState(() {
_isLoading = true;
});
final session = _sessionNotifier.session;
if (session == null) {
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
setState(() {
_isLoading = false;
});
return;
}
final String userId = session.userId;
final String? userName = session.userName;
// 1. 컨트롤러 생성 및 시작
final gameController = CardFlipController();
gameController.setUserInfo(userId, userName);
gameController.startNewGame(level);
setState(() {
_isLoading = false;
});
if (!mounted) return;
// 2. 게임 화면으로 이동 (Controller 주입)
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: gameController,
child: const CardFlipGameScreen(),
),
),
);
// 3. 게임 종료 후 레벨 잠금 상태만 새로고침
_loadProgress(forceRefreshRanks: false);
}
@override
Widget build(BuildContext context) {
context.watch<ThemeNotifier>();
context.watch<SessionNotifier>();
final bool allLevelsUnlocked =
_maxUnlockedLevel >= CardFlipDifficulties.allDifficulties.length;
final theme = Theme.of(context);
return CommonGameShell(
title: '카드 뒤집기 (기억력)',
onRankingPressed: () {
// 랭킹 화면 호출
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
gameType: 'CARD_FLIP',
difficulties: CardFlipDifficulties.allDifficulties,
initialDifficultyName:
CardFlipDifficulties.getLevel(_maxUnlockedLevel).name,
),
),
);
},
body: LayoutBuilder(
builder: (context, constraints) {
const double maxContentRatio = 0.6;
final double constrainedWidth =
(constraints.maxHeight * maxContentRatio) > 500
? 500
: (constraints.maxHeight * maxContentRatio);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: constrainedWidth),
child: Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () => _loadProgress(forceRefreshRanks: true),
child: ListView.builder(
itemCount: CardFlipDifficulties.allDifficulties.length,
itemBuilder: (context, index) {
final CardFlipDifficulty level =
CardFlipDifficulties.allDifficulties[index];
final bool isUnlocked = allLevelsUnlocked ||
level.levelIndex <= _maxUnlockedLevel;
final (int oldRank, int currentRank) =
_rankHistory[level.levelIndex] ?? (0, 0);
Widget? trailingWidget = isUnlocked
? const Icon(Icons.play_arrow_rounded)
: null;
String? subtitleText;
Color? subtitleColor;
// 랭킹 표시 로직
if (currentRank > 0) {
String rankStr = "${currentRank}";
subtitleText = "$rankStr (확인됨)";
subtitleColor = Colors.blue;
trailingWidget = const Icon(
Icons.new_releases_rounded,
color: Colors.blue,
size: 28);
}
return Card(
margin: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 4.0),
child: ListTile(
leading: Icon(
isUnlocked
? Icons.lock_open_rounded
: Icons.lock_rounded,
color: isUnlocked ? theme.primaryColor : Colors.grey,
),
title: Text(level.name,
style: TextStyle(
fontSize: 18,
fontWeight: isUnlocked
? FontWeight.bold
: FontWeight.normal,
color: isUnlocked
? theme.textTheme.bodyLarge?.color
: Colors.grey,
)),
subtitle: subtitleText != null
? Text(subtitleText,
style: TextStyle(
color: subtitleColor,
fontWeight: FontWeight.bold))
: null,
trailing: trailingWidget,
onTap: isUnlocked && !_isLoading
? () => _startGame(level)
: null,
),
);
},
),
),
),
],
),
),
);
},
),
);
}
}