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
+31
View File
@@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
+10
View File
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: package
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,276 @@
// packages/feature_game_sequence/lib/controllers/sequence_game_controller.dart
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import '../models/sequence_models.dart';
enum SequenceGameState {
idle,
presenting,
postDelay,
input,
processing,
completed,
}
class SequenceGameController with ChangeNotifier {
late SequenceDifficulty difficulty;
late String userId;
late String? userName;
SequenceGameState _currentState = SequenceGameState.idle;
SequenceGameState get currentState => _currentState;
List<SequenceButtonId> _currentSequence = [];
List<SequenceButtonId> get currentSequence => _currentSequence;
List<SequenceButtonId> _userInput = [];
List<SequenceButtonId> get userInput => _userInput;
late List<String> _activeSymbolPool;
List<String> get activeSymbolPool => _activeSymbolPool;
int _currentSequenceLength = 0;
int get currentSequenceLength => _currentSequenceLength;
int _maxAchievedLength = 0;
int get maxAchievedLength => _maxAchievedLength;
int _roundsCompleted = 0;
int get roundsCompleted => _roundsCompleted;
int _roundsFailed = 0;
int get roundsFailed => _roundsFailed;
SequenceButtonId? _activePresentationId;
SequenceButtonId? get activePresentationId => _activePresentationId;
bool _isLastInputCorrect = true;
bool get isLastInputCorrect => _isLastInputCorrect;
bool _showFeedback = false;
bool get showFeedback => _showFeedback;
Timer? _scoreTimer;
Timer? _roundTimer;
double _remainingRoundTime = 0.0;
double get remainingRoundTime => _remainingRoundTime;
int _secondsElapsed = 0;
int get secondsElapsed => _secondsElapsed;
int _presentationStep = 0;
int get presentationStep => _presentationStep;
set presentationStep(int step) {
_presentationStep = step;
notifyListeners();
}
final Random _random = Random();
String getSymbolForId(SequenceButtonId id) {
if (id.index >= _activeSymbolPool.length) {
return '?';
}
return _activeSymbolPool[id.index];
}
void setUserInfo(String userId, String? userName) {
this.userId = userId;
this.userName = userName;
}
void startNewGame(SequenceDifficulty level) {
difficulty = level;
_currentSequence.clear();
_userInput.clear();
_currentSequenceLength = level.initialLength;
_maxAchievedLength = level.initialLength - 1;
_currentState = SequenceGameState.idle;
_secondsElapsed = 0;
_roundsCompleted = 0;
_roundsFailed = 0;
_showFeedback = false;
_activePresentationId = null;
_presentationStep = 0;
_initializeSymbolPool();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startPresentationPhase();
});
notifyListeners();
}
void restartGame() {
startNewGame(difficulty);
}
void setActivePresentation(SequenceButtonId? id) {
_activePresentationId = id;
notifyListeners();
}
// 🔽 [🔥 수정] 레벨 10 이상만 믹스, 나머지는 단일 테마
void _initializeSymbolPool() {
if (difficulty.levelIndex >= 10) {
// Lv 10+: 모든 풀을 합치고 섞음 (Mixed)
final List<String> allSymbols = [
...SequenceDifficulties.getSymbolPool(SequenceContentPool.number),
...SequenceDifficulties.getSymbolPool(SequenceContentPool.letter),
...SequenceDifficulties.getSymbolPool(SequenceContentPool.koreanChar),
...SequenceDifficulties.getSymbolPool(SequenceContentPool.emoji),
// (단어는 길이가 길어 혼합 시 시각적 밸런스를 위해 제외하거나 포함 가능. 여기선 제외)
];
_activeSymbolPool = (allSymbols..shuffle()).sublist(0, difficulty.buttonsCount);
} else {
// Lv 1~9: 지정된 단일 풀 사용
_activeSymbolPool = SequenceDifficulties.getSymbolPool(difficulty.contentPoolType);
}
}
void _startPresentationPhase() {
_roundTimer?.cancel();
_currentState = SequenceGameState.presenting;
_presentationStep = 0;
_generateNextSequence();
notifyListeners();
}
void _generateNextSequence() {
_currentSequence.clear();
final int maxIndex = difficulty.buttonsCount;
for (int i = 0; i < _currentSequenceLength; i++) {
final int randIndex = _random.nextInt(maxIndex);
_currentSequence.add(SequenceButtonId.values[randIndex]);
}
}
void runPostPresentationDelay() {
_currentState = SequenceGameState.postDelay;
notifyListeners();
Future.delayed(const Duration(milliseconds: 1000), () {
if (currentState == SequenceGameState.postDelay) {
startInputPhase();
}
});
}
void startInputPhase() {
_startScoreTimer();
_userInput.clear();
_currentState = SequenceGameState.input;
_startRoundTimer();
notifyListeners();
}
void _startRoundTimer() {
_roundTimer?.cancel();
_remainingRoundTime = difficulty.roundTimeLimit;
_roundTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (_remainingRoundTime <= 0) {
_roundTimer?.cancel();
_roundsFailed++;
_handleFeedbackAndTermination();
} else {
_remainingRoundTime -= 0.1;
notifyListeners();
}
});
}
void _startScoreTimer() {
_scoreTimer?.cancel();
_scoreTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
_secondsElapsed++;
});
}
void recordUserInput(SequenceButtonId inputId) {
if (_currentState != SequenceGameState.input || _showFeedback) return;
_userInput.add(inputId);
_currentState = SequenceGameState.processing;
notifyListeners();
Future.delayed(const Duration(milliseconds: 100), () {
_checkUserInput();
});
}
void _checkUserInput() {
if (_currentState != SequenceGameState.processing) return;
final int inputLength = _userInput.length;
final int currentInputIndex = inputLength - 1;
if (_userInput[currentInputIndex] != _currentSequence[currentInputIndex]) {
_roundsFailed++;
_isLastInputCorrect = false;
_handleFeedbackAndTermination();
return;
}
_isLastInputCorrect = true;
if (inputLength == _currentSequenceLength) {
_roundsCompleted++;
_maxAchievedLength = _currentSequenceLength;
_handleFeedbackAndNextRound();
return;
}
_currentState = SequenceGameState.input;
notifyListeners();
}
void _handleFeedbackAndTermination() {
_stopAllTimers();
_showFeedback = true;
notifyListeners();
Future.delayed(const Duration(seconds: 1), () {
_stopAllTimers();
_currentState = SequenceGameState.completed;
_showFeedback = false;
notifyListeners();
});
}
void _handleFeedbackAndNextRound() {
_roundTimer?.cancel();
_showFeedback = true;
notifyListeners();
Future.delayed(const Duration(milliseconds: 1000), () {
_showFeedback = false;
if (_currentSequenceLength < difficulty.maxGameLength) {
_currentSequenceLength++;
} else {
_stopAllTimers();
_currentState = SequenceGameState.completed;
notifyListeners();
return;
}
_startPresentationPhase();
});
}
void _stopAllTimers() {
_scoreTimer?.cancel();
_roundTimer?.cancel();
}
@override
void dispose() {
_stopAllTimers();
super.dispose();
}
}
@@ -0,0 +1,2 @@
export 'screens/sequence_lobby_screen.dart';
export 'screens/sequence_game_screen.dart';
@@ -0,0 +1,108 @@
// packages/feature_game_sequence/lib/models/sequence_models.dart
import 'package:service_api/service_api.dart';
enum SequenceButtonId {
btn0, btn1, btn2, btn3,
btn4, btn5, btn6, btn7, btn8, btn9
}
enum SequenceContentPool {
number, // 1, 2, 3...
letter, // A, B, C...
koreanChar, // 가, 나, 다...
emoji, // 이모지
wordVeg, // 야채
wordFruit, // 과일
wordItem, // 사물
wordNature, // 자연
wordPlace, // 장소
mixed, // [🔥 의미적 구분용] 실제로는 컨트롤러에서 섞음
}
class SequenceDifficulty extends GameDifficulty {
final int levelIndex;
final int initialLength;
final int maxGameLength;
final int buttonsCount;
final double roundTimeLimit;
final double presentationSpeed;
final SequenceContentPool contentPoolType;
const SequenceDifficulty({
required this.levelIndex,
required super.name,
required super.contextId,
required this.initialLength,
required this.maxGameLength,
required this.buttonsCount,
required this.roundTimeLimit,
required this.presentationSpeed,
required this.contentPoolType,
});
}
class SequenceDifficulties {
// --- 콘텐츠 풀 데이터 ---
static const List<String> pool_Number = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
static const List<String> pool_Letter = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"];
static const List<String> pool_KoreanChar = ["", "", "", "", "", "", "", "", "", ""];
static const List<String> pool_Emoji = ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯"];
static const List<String> pool_Veg = ["당근", "오이", "가지", "배추", "", "", "고추", "마늘", "감자", "양파"];
static const List<String> pool_Fruit = ["사과", "", "포도", "수박", "딸기", "참외", "", "자두", "복숭아", ""];
static const List<String> pool_Item = ["시계", "안경", "모자", "가방", "우산", "신발", "거울", "", "수건", "비누"];
static const List<String> pool_Nature = ["하늘", "구름", "바람", "", "", "", "", "", "", "나무"];
static const List<String> pool_Place = ["버스", "기차", "학교", "병원", "시장", "공원", "", "바다", "", "마트"];
static List<String> getSymbolPool(SequenceContentPool type) {
switch (type) {
case SequenceContentPool.number: return pool_Number;
case SequenceContentPool.letter: return pool_Letter;
case SequenceContentPool.koreanChar: return pool_KoreanChar;
case SequenceContentPool.emoji: return pool_Emoji;
case SequenceContentPool.wordVeg: return pool_Veg;
case SequenceContentPool.wordFruit: return pool_Fruit;
case SequenceContentPool.wordItem: return pool_Item;
case SequenceContentPool.wordNature: return pool_Nature;
case SequenceContentPool.wordPlace: return pool_Place;
default: return pool_Number;
}
}
// [🔥 수정] 15단계 난이도 (Lv 1~9: 단일 테마, Lv 10+: 믹스)
static final List<SequenceDifficulty> allDifficulties = [
// --- Phase 1: 기호/숫자 (단일) ---
const SequenceDifficulty(levelIndex: 1, name: 'Lv. 1: 입문 (숫자/3버튼)', contextId: 'SEQ_L1_NUM', initialLength: 2, maxGameLength: 4, buttonsCount: 3, roundTimeLimit: 20.0, presentationSpeed: 1.5, contentPoolType: SequenceContentPool.number),
const SequenceDifficulty(levelIndex: 2, name: 'Lv. 2: 초급 (알파벳/4버튼)', contextId: 'SEQ_L2_LETTER', initialLength: 3, maxGameLength: 5, buttonsCount: 4, roundTimeLimit: 15.0, presentationSpeed: 1.2, contentPoolType: SequenceContentPool.letter),
const SequenceDifficulty(levelIndex: 3, name: 'Lv. 3: 기초 (한글/4버튼)', contextId: 'SEQ_L3_KOR', initialLength: 3, maxGameLength: 7, buttonsCount: 4, roundTimeLimit: 12.0, presentationSpeed: 1.0, contentPoolType: SequenceContentPool.koreanChar),
// --- Phase 2: 이모지/단어 (단일) ---
const SequenceDifficulty(levelIndex: 4, name: 'Lv. 4: 중급 (이모지/4버튼)', contextId: 'SEQ_L4_EMOJI', initialLength: 4, maxGameLength: 9, buttonsCount: 4, roundTimeLimit: 10.0, presentationSpeed: 0.9, contentPoolType: SequenceContentPool.emoji),
const SequenceDifficulty(levelIndex: 5, name: 'Lv. 5: 중급 (야채/6버튼)', contextId: 'SEQ_L5_VEG', initialLength: 4, maxGameLength: 7, buttonsCount: 6, roundTimeLimit: 12.0, presentationSpeed: 0.9, contentPoolType: SequenceContentPool.wordVeg),
const SequenceDifficulty(levelIndex: 6, name: 'Lv. 6: 숙련 (과일/6버튼)', contextId: 'SEQ_L6_FRUIT', initialLength: 5, maxGameLength: 10, buttonsCount: 6, roundTimeLimit: 10.0, presentationSpeed: 0.8, contentPoolType: SequenceContentPool.wordFruit),
// --- Phase 3: 단어 심화 (단일) ---
const SequenceDifficulty(levelIndex: 7, name: 'Lv. 7: 상급 (사물/6버튼)', contextId: 'SEQ_L7_ITEM', initialLength: 5, maxGameLength: 12, buttonsCount: 6, roundTimeLimit: 10.0, presentationSpeed: 0.8, contentPoolType: SequenceContentPool.wordItem),
const SequenceDifficulty(levelIndex: 8, name: 'Lv. 8: 전문가 (자연/8버튼)', contextId: 'SEQ_L8_NATURE', initialLength: 5, maxGameLength: 10, buttonsCount: 8, roundTimeLimit: 12.0, presentationSpeed: 0.7, contentPoolType: SequenceContentPool.wordNature),
const SequenceDifficulty(levelIndex: 9, name: 'Lv. 9: 전문가 (장소/8버튼)', contextId: 'SEQ_L9_PLACE', initialLength: 6, maxGameLength: 15, buttonsCount: 8, roundTimeLimit: 10.0, presentationSpeed: 0.7, contentPoolType: SequenceContentPool.wordPlace),
// --- Phase 4: 마스터 (믹스 - Lv 10부터 시작) ---
// (contentPoolType은 mixed로 표시하지만 실제로는 Controller에서 처리)
const SequenceDifficulty(levelIndex: 10, name: 'Lv. 10: 믹스 (8버튼)', contextId: 'SEQ_L10_MIX_8', initialLength: 6, maxGameLength: 20, buttonsCount: 8, roundTimeLimit: 10.0, presentationSpeed: 0.6, contentPoolType: SequenceContentPool.mixed),
const SequenceDifficulty(levelIndex: 11, name: 'Lv. 11: 믹스 (10버튼)', contextId: 'SEQ_L11_MIX_10', initialLength: 6, maxGameLength: 15, buttonsCount: 10, roundTimeLimit: 10.0, presentationSpeed: 0.6, contentPoolType: SequenceContentPool.mixed),
const SequenceDifficulty(levelIndex: 12, name: 'Lv. 12: 레전드 (10버튼)', contextId: 'SEQ_L12_LEGEND', initialLength: 7, maxGameLength: 25, buttonsCount: 10, roundTimeLimit: 8.0, presentationSpeed: 0.5, contentPoolType: SequenceContentPool.mixed),
// --- Phase 5: 신의 영역 (믹스 + 고속) ---
const SequenceDifficulty(levelIndex: 13, name: 'Lv. 13: 갓모드 (10버튼)', contextId: 'SEQ_L13_GOD', initialLength: 7, maxGameLength: 30, buttonsCount: 10, roundTimeLimit: 6.0, presentationSpeed: 0.4, contentPoolType: SequenceContentPool.mixed),
const SequenceDifficulty(levelIndex: 14, name: 'Lv. 14: 갓모드 (기억)', contextId: 'SEQ_L14_GOD_MEM', initialLength: 8, maxGameLength: 40, buttonsCount: 10, roundTimeLimit: 5.0, presentationSpeed: 0.4, contentPoolType: SequenceContentPool.mixed),
const SequenceDifficulty(levelIndex: 15, name: 'Lv. 15: 엔드게임', contextId: 'SEQ_L15_END', initialLength: 8, maxGameLength: 50, buttonsCount: 10, roundTimeLimit: 5.0, presentationSpeed: 0.3, contentPoolType: SequenceContentPool.mixed),
];
static SequenceDifficulty getLevel(int levelIndex) {
if (levelIndex < 1) levelIndex = 1;
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
orElse: () => allDifficulties[0]);
}
}
@@ -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,
),
);
},
),
),
),
],
),
),
);
},
),
);
}
}
@@ -0,0 +1,85 @@
// packages/feature_game_sequence/lib/widgets/sequence_button_widget.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/sequence_models.dart';
import '../controllers/sequence_game_controller.dart';
class SequenceButtonWidget extends StatelessWidget {
final SequenceButtonId id;
final VoidCallback? onTap;
const SequenceButtonWidget({
super.key,
required this.id,
this.onTap,
});
Color _getButtonColor() {
switch (id) {
case SequenceButtonId.btn0: return Colors.green.shade400;
case SequenceButtonId.btn1: return Colors.red.shade400;
case SequenceButtonId.btn2: return Colors.blue.shade400;
case SequenceButtonId.btn3: return Colors.yellow.shade400;
case SequenceButtonId.btn4: return Colors.purple.shade400;
case SequenceButtonId.btn5: return Colors.orange.shade400;
case SequenceButtonId.btn6: return Colors.teal.shade400;
case SequenceButtonId.btn7: return Colors.pink.shade400;
case SequenceButtonId.btn8: return Colors.indigo.shade400;
case SequenceButtonId.btn9: return Colors.cyan.shade400;
default: return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
final controller = context.watch<SequenceGameController>();
final isInputPhase = controller.currentState == SequenceGameState.input;
final bool isActive =
controller.activePresentationId == id ||
(isInputPhase && controller.userInput.lastOrNull == id);
final String buttonSymbol = controller.getSymbolForId(id);
final Color baseColor = _getButtonColor();
final Color activeColor = baseColor.withOpacity(1.0).withAlpha(255);
final Color inactiveColor = baseColor.withOpacity(onTap == null ? 0.4 : 0.8);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: isActive ? activeColor : inactiveColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isActive ? Colors.white : Colors.transparent,
width: isActive ? 4 : 0
),
boxShadow: isActive
? [BoxShadow(color: activeColor.withOpacity(0.8), blurRadius: 10, offset: const Offset(0, 0))]
: [const BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(2, 2))],
),
child: Center(
// 🔽 [🔥 수정] FittedBox를 사용하여 텍스트 크기 자동 조절 (단어 대응)
child: Padding(
padding: const EdgeInsets.all(4.0),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
buttonSymbol,
style: const TextStyle(
fontSize: 28, // 기본 폰트 크기
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,28 @@
name: feature_game_sequence
description: "A new Flutter package project."
version: 1.0.0
publish_to: 'none'
resolution: workspace
environment:
sdk: '^3.9.2'
flutter: '>=3.10.0'
dependencies:
flutter:
sdk: flutter
# 1. 공통 서비스 로직 (필수)
# GameLevel, SudokuGameDto, SudokuTheme, PuzzleService, IdentityService 등을 사용
service_api:
path: ../service_api
# 2. UI 및 상태 관리
provider: ^6.0.0 # (GameScreen에서 ThemeNotifier를 watch)
feature_common:
path: ../feature_common
dev_dependencies:
flutter_test:
sdk: flutter
lints: ^3.0.0
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_game_sequence/feature_game_sequence.dart';
void main() {
test('adds one to input values', () {
final calculator = Calculator();
expect(calculator.addOne(2), 3);
expect(calculator.addOne(-7), -6);
expect(calculator.addOne(0), 1);
});
}