...
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
// packages/feature_game_sequence/lib/screens/sequence_game_screen.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../controllers/sequence_game_controller.dart';
|
||||
import '../models/sequence_models.dart';
|
||||
import '../widgets/sequence_button_widget.dart';
|
||||
|
||||
class SequenceGameScreen extends StatefulWidget {
|
||||
const SequenceGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SequenceGameScreen> createState() => _SequenceGameScreenState();
|
||||
}
|
||||
|
||||
class _SequenceGameScreenState extends State<SequenceGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
Timer? _presentationTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SequenceGameController>(context, listen: false);
|
||||
|
||||
controller.addListener(() {
|
||||
if (controller.currentState == SequenceGameState.presenting && _presentationTimer == null) {
|
||||
_runPresentationAnimation(controller);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_presentationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _runPresentationAnimation(SequenceGameController controller) {
|
||||
_presentationTimer?.cancel();
|
||||
|
||||
final List<SequenceButtonId> sequence = controller.currentSequence;
|
||||
final double speed = controller.difficulty.presentationSpeed;
|
||||
final Duration stepDuration = Duration(milliseconds: (speed * 1000).round());
|
||||
|
||||
int step = 0;
|
||||
|
||||
_presentationTimer = Timer.periodic(stepDuration, (timer) {
|
||||
if (step < sequence.length) {
|
||||
controller.setActivePresentation(sequence[step]);
|
||||
controller.presentationStep = step + 1;
|
||||
step++;
|
||||
} else {
|
||||
timer.cancel();
|
||||
controller.setActivePresentation(null);
|
||||
_presentationTimer = null;
|
||||
|
||||
if (mounted) {
|
||||
controller.runPostPresentationDelay();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showGameCompletion(SequenceGameController controller) async {
|
||||
String formatSequenceScore(int primary, int? secondary) {
|
||||
final roundsCompleted = primary;
|
||||
final roundsFailed = secondary ?? 0;
|
||||
return '성공 ${roundsCompleted}회 / 실패 ${roundsFailed}회';
|
||||
}
|
||||
|
||||
Future<void> saveSequenceProgress(String playerName) async {
|
||||
final bool isLevelClear = controller.maxAchievedLength >= controller.difficulty.maxGameLength;
|
||||
|
||||
if (!isLevelClear) {
|
||||
debugPrint("레벨 클리어 실패: 도달(${controller.maxAchievedLength}) < 목표(${controller.difficulty.maxGameLength})");
|
||||
return;
|
||||
}
|
||||
|
||||
final identityService = IdentityService();
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SEQUENCE');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevelIndex = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevelIndex > SequenceDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SEQUENCE');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevelIndex, gameType: 'SEQUENCE');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SEQUENCE',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.roundsCompleted,
|
||||
secondaryScore: controller.roundsFailed,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSequenceScore,
|
||||
onProgressSave: saveSequenceProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [🔥 수정] 순서 제시 상태 표시 바
|
||||
Widget _buildSequenceDisplay(SequenceGameController controller) {
|
||||
final bool isPresenting = controller.currentState == SequenceGameState.presenting;
|
||||
|
||||
// [🔥 핵심 수정] processing 상태도 입력 상태로 간주해야 점멸 현상이 사라짐
|
||||
final bool isInput = controller.currentState == SequenceGameState.input ||
|
||||
controller.currentState == SequenceGameState.processing;
|
||||
|
||||
final bool isDelay = controller.currentState == SequenceGameState.postDelay;
|
||||
|
||||
final int displayLength = controller.currentSequenceLength;
|
||||
final int stepsShown = controller.presentationStep;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(displayLength, (index) {
|
||||
|
||||
String symbol = "?";
|
||||
if (isInput || isDelay) {
|
||||
if (index < controller.userInput.length) {
|
||||
symbol = controller.activeSymbolPool[controller.userInput[index].index];
|
||||
}
|
||||
} else if (isPresenting) {
|
||||
if (index < stepsShown) {
|
||||
symbol = controller.activeSymbolPool[controller.currentSequence[index].index];
|
||||
}
|
||||
}
|
||||
|
||||
final bool isCurrentStep = isPresenting && index == stepsShown - 1;
|
||||
// [🔥 추가] 입력 중인 칸 강조
|
||||
final bool isCurrentInput = isInput && index == controller.userInput.length;
|
||||
|
||||
return Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentStep ? Colors.orange.shade300 : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
// 입력 대기 커서 강조
|
||||
color: isCurrentInput ? Theme.of(context).primaryColor : Colors.transparent,
|
||||
width: 2,
|
||||
)
|
||||
),
|
||||
child: Center(
|
||||
child: Text(symbol, style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: (isInput && index >= controller.userInput.length) ? Colors.black38 : Colors.black,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedbackWidget(SequenceGameController controller, ThemeData theme) {
|
||||
if (controller.showFeedback) {
|
||||
final String text = controller.isLastInputCorrect ? "성공! 다음 순서로..." : "오답! 🚨";
|
||||
final Color color = controller.isLastInputCorrect ? Colors.green : theme.colorScheme.error;
|
||||
|
||||
return Text(text, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color));
|
||||
}
|
||||
|
||||
if (controller.currentState == SequenceGameState.presenting) {
|
||||
return const Text(" ", style: TextStyle(fontSize: 24));
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<SequenceGameController>();
|
||||
final state = controller.currentState;
|
||||
final theme = Theme.of(context); // 👈 [수정] theme 변수 정의 확인
|
||||
|
||||
if (state == SequenceGameState.completed && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _showGameCompletion(controller);
|
||||
});
|
||||
}
|
||||
|
||||
String statusText;
|
||||
Color statusColor = Colors.grey;
|
||||
if (state == SequenceGameState.presenting) {
|
||||
statusText = "👀 순서 제시 중...";
|
||||
statusColor = Colors.orange;
|
||||
} else if (state == SequenceGameState.postDelay) {
|
||||
statusText = "준비! ☝️";
|
||||
statusColor = Colors.green;
|
||||
} else if (state == SequenceGameState.input || state == SequenceGameState.processing) {
|
||||
statusText = "👉 입력 대기 중 (${controller.remainingRoundTime.toStringAsFixed(1)}s)";
|
||||
statusColor = theme.primaryColor;
|
||||
} else if (state == SequenceGameState.completed) {
|
||||
bool isClear = controller.maxAchievedLength >= controller.difficulty.maxGameLength;
|
||||
statusText = isClear ? "목표 달성! 🎉" : "게임 종료 (실패)";
|
||||
statusColor = isClear ? Colors.green : Colors.red;
|
||||
} else {
|
||||
statusText = "시작 대기 중";
|
||||
}
|
||||
|
||||
final List<SequenceButtonId> availableButtons = SequenceButtonId.values.sublist(0, controller.difficulty.buttonsCount);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("길이: ${controller.currentSequenceLength} / ${controller.difficulty.maxGameLength}"),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Center(
|
||||
child: Text("시간: ${(controller.secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(controller.secondsElapsed % 60).toString().padLeft(2, '0')}", style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildSequenceDisplay(controller),
|
||||
|
||||
_buildFeedbackWidget(controller, theme),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Text(statusText, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: statusColor)),
|
||||
Text("최고 기록: ${controller.maxAchievedLength} | 성공: ${controller.roundsCompleted}", style: const TextStyle(fontSize: 16, color: Colors.grey)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: controller.difficulty.buttonsCount > 4 ? 3 : 2,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.5,
|
||||
),
|
||||
itemCount: availableButtons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final buttonId = availableButtons[index];
|
||||
|
||||
return SequenceButtonWidget(
|
||||
id: buttonId,
|
||||
onTap: (state == SequenceGameState.input && !controller.showFeedback)
|
||||
? () => controller.recordUserInput(buttonId)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
if (state == SequenceGameState.completed)
|
||||
ElevatedButton(
|
||||
onPressed: controller.restartGame,
|
||||
child: const Text('다시 시작', style: TextStyle(fontSize: 20)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// packages/feature_game_sequence/lib/screens/sequence_lobby_screen.dart
|
||||
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// [C] 공통 서비스
|
||||
import 'package:service_api/service_api.dart';
|
||||
// [A] 공통 UI 셸
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
// [B] 순서 기억 게임 모델/컨트롤러
|
||||
import '../models/sequence_models.dart';
|
||||
import 'sequence_game_screen.dart';
|
||||
import '../controllers/sequence_game_controller.dart';
|
||||
|
||||
class SequenceLobbyScreen extends StatefulWidget {
|
||||
const SequenceLobbyScreen({ super.key });
|
||||
@override
|
||||
State<SequenceLobbyScreen> createState() => _SequenceLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SequenceLobbyScreenState extends State<SequenceLobbyScreen> {
|
||||
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('SEQUENCE');
|
||||
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<SequenceDifficulty>(
|
||||
gameType: 'SEQUENCE',
|
||||
myName: myName,
|
||||
allLevels: SequenceDifficulties.allDifficulties,
|
||||
getLevelIndex: (level) => level.levelIndex,
|
||||
);
|
||||
if (mounted) { setState(() { _rankHistory = rankHistory; }); }
|
||||
log("순서 기억 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SequenceLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [게임 시작] 컨트롤러 생성 및 화면 이동
|
||||
Future<void> _startGame(SequenceDifficulty 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 = SequenceGameController();
|
||||
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 SequenceGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 게임 종료 후 레벨 잠금 상태만 새로고침
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
context.watch<SessionNotifier>();
|
||||
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= SequenceDifficulties.allDifficulties.length;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 🔽 [핵심] CommonGameShell 사용
|
||||
return CommonGameShell(
|
||||
title: '순서 기억 퀴즈 (Simon)',
|
||||
onRankingPressed: () {
|
||||
// 랭킹 화면 호출
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SEQUENCE',
|
||||
difficulties: SequenceDifficulties.allDifficulties, // 👈 [수정] 바로 전달
|
||||
initialDifficultyName: SequenceDifficulties.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: SequenceDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SequenceDifficulty level = SequenceDifficulties.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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user