...
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
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/spider_game_controller.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../widgets/tableau_pile_widget.dart';
|
||||
import '../widgets/bottom_bar_widget.dart';
|
||||
import '../widgets/card_widget.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class SpiderGameScreen extends StatefulWidget {
|
||||
const SpiderGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpiderGameScreen> createState() => _SpiderGameScreenState();
|
||||
}
|
||||
|
||||
class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
final List<GlobalKey> _tableauKeys = List.generate(10, (_) => GlobalKey());
|
||||
final GlobalKey _stockKey = GlobalKey();
|
||||
final GlobalKey _bodyStackKey = GlobalKey();
|
||||
final List<Widget> _animationOverlays = [];
|
||||
bool _showDimOverlay = false;
|
||||
|
||||
VoidCallback? _controllerListener;
|
||||
|
||||
bool _isDealAnimationRunning = false;
|
||||
bool _isStackAnimationRunning = false;
|
||||
|
||||
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
|
||||
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
|
||||
return AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
title: Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final seconds = controller.secondsElapsed;
|
||||
final timeStr = "${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}";
|
||||
return Text(timeStr, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22));
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final bool canUndo = controller.canUndo;
|
||||
return IconButton(
|
||||
icon: Icon(Icons.undo, color: canUndo ? null : Colors.grey),
|
||||
onPressed: canUndo ? () {
|
||||
final int currentCount = controller.undo();
|
||||
if (currentCount >= SpiderGameController.maxUndoCount) {
|
||||
_showSurrenderDialog(context);
|
||||
}
|
||||
} : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
Provider.of<SpiderGameController>(context, listen: false).restartGame();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
void _showSurrenderDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('게임 포기'),
|
||||
content: const Text('되돌리기 횟수를 모두 사용했습니다. 게임을 포기하고 로비로 돌아가시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(child: const Text('취소'), onPressed: () => Navigator.of(ctx).pop()),
|
||||
TextButton(
|
||||
child: const Text('포기하기'),
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
_controllerListener = () {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
|
||||
// 🔽 [수정] 덱 분배 애니메이션 (플래그 가드 추가)
|
||||
if (controller.cardsToDealAnimate.isNotEmpty && !_isDealAnimationRunning) {
|
||||
_isDealAnimationRunning = true; // 👈 [잠금]
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
|
||||
_runDealAnimation(controller, cardWidth, cardHeight);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
|
||||
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
|
||||
_isStackAnimationRunning = true; // 👈 [잠금]
|
||||
|
||||
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
|
||||
final List<SpiderCard> cardsToAnimate = List.of(controller.cardsToAnimateStack);
|
||||
final int sourceIndex = controller.animationSourcePileIndex;
|
||||
final int targetIndex = controller.animationTargetFoundationIndex;
|
||||
|
||||
// 큐를 즉시 비웁니다.
|
||||
controller.clearStackAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToAnimateStack (Source: $sourceIndex). Running animation...");
|
||||
// 복사한 데이터를 인자로 전달합니다.
|
||||
_runStackCompletionAnimation(controller, cardWidth, cardHeight, cardsToAnimate, sourceIndex, targetIndex);
|
||||
}
|
||||
};
|
||||
|
||||
controller.addListener(_controllerListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controllerListener != null) {
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
controller.removeListener(_controllerListener!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint("[LOG] SpiderGameScreen: --- Main Build Method CALLED ---");
|
||||
|
||||
final controller = context.read<SpiderGameController>();
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
final double cardOverlap = cardHeight * 0.4;
|
||||
|
||||
final bool isGameCompleted = context.select((SpiderGameController c) => c.isGameCompleted);
|
||||
if (isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_runGameWinAnimation(context, controller, cardWidth, cardHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildGameAppBar(context, controller),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
key: _bodyStackKey,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFF008000),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
debugPrint("[LOG] Tableau Consumer: Rebuilding");
|
||||
return Stack(
|
||||
children: List.generate(10, (index) {
|
||||
return Positioned(
|
||||
left: index * (cardWidth + cardGap),
|
||||
top: 0,
|
||||
child: TableauPileWidget(
|
||||
key: _tableauKeys[index],
|
||||
pileIndex: index,
|
||||
cards: controller.currentState.tableau[index],
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
cardOverlap: cardOverlap,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const AdBannerWidget(),
|
||||
BottomBarWidget(
|
||||
key: _stockKey,
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_showDimOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
|
||||
..._animationOverlays,
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션 (오버레이)
|
||||
void _runDealAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
// 🔽 [수정] 덱 분배 애니메이션도 경주 조건을 피하기 위해 인자로 받도록 수정
|
||||
final List<SpiderCard> cardsToDeal = List.of(controller.cardsToDealAnimate);
|
||||
controller.clearDealAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] _runDealAnimation: Starting. ${cardsToDeal.length} cards.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null || stockBox == null) {
|
||||
debugPrint("[LOG] _runDealAnimation: FAILED (Keys not ready)");
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = stockBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < cardsToDeal.length; i++) {
|
||||
final card = cardsToDeal[i];
|
||||
final RenderBox? targetBox = _tableauKeys[i].currentContext?.findRenderObject() as RenderBox?;
|
||||
if (targetBox == null) continue;
|
||||
|
||||
final Offset globalEndPos = targetBox.localToGlobal(Offset.zero);
|
||||
final double targetY = globalEndPos.dy + controller.currentState.tableau[i].length * (cardHeight * 0.4);
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(Offset(globalEndPos.dx, targetY));
|
||||
|
||||
final animationDelayMs = i * 100;
|
||||
final animationDurationMs = 600;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('deal_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
final bool isFlipping = value > 0.5;
|
||||
final double rotationY = isFlipping ? (value - 0.5) * 2 * pi : 0;
|
||||
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(rotationY),
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = (value > 0.5),
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
|
||||
if (i == cardsToDeal.length - 1) {
|
||||
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
|
||||
controller.finalizeDealFromStock(cardsToDeal);
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 스택 완성 애니메이션 (오버레이)
|
||||
void _runStackCompletionAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
// 🔽 [수정] 인자를 받습니다.
|
||||
List<SpiderCard> cardsToAnimate,
|
||||
int sourceIndex,
|
||||
int targetIndex,
|
||||
) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Starting. ${cardsToAnimate.length} cards from index $sourceIndex.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
// 🔽 [수정] 인자로 받은 sourceIndex 사용
|
||||
final RenderBox? startBox = _tableauKeys[sourceIndex].currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (bodyStackBox == null || stockBox == null || startBox == null) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: FAILED (Keys not ready for index $sourceIndex)");
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = startBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
// 🔽 [수정] 스택이 제거되기 '전'의 길이를 기준으로 계산 (정확)
|
||||
final double startY = localStartPos.dy + (controller.currentState.tableau[sourceIndex].length - cardsToAnimate.length) * (cardHeight * 0.4);
|
||||
|
||||
// 🔽 [수정] 인자로 받은 targetIndex 사용
|
||||
final Offset globalEndPos = stockBox.localToGlobal(Offset( (targetIndex * (cardWidth * 0.15)) - cardWidth*3, 10));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
for (int i = 0; i < cardsToAnimate.length; i++) {
|
||||
final card = cardsToAnimate[i];
|
||||
final animationDelayMs = i * 80;
|
||||
final animationDurationMs = 400;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('stack_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(Offset(localStartPos.dx, startY + (i * cardHeight * 0.4)), localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
if (i == cardsToAnimate.length - 1) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Animation FINISHED. Calling finalizeStackCompletion for index $sourceIndex.");
|
||||
// 🔽 [수정] finalize가 어떤 스택을 처리할지 인덱스를 전달
|
||||
controller.finalizeStackCompletion(cardsToAnimate, sourceIndex);
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
|
||||
void _runGameWinAnimation(
|
||||
BuildContext context,
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null) return;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final random = Random();
|
||||
final List<SpiderCard> allCards = controller.currentState.foundation.expand((pile) => pile).toList();
|
||||
final Offset globalStartPos = Offset(screenSize.width / 2, screenSize.height * 0.8);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < allCards.length; i++) {
|
||||
final card = allCards[i];
|
||||
final animationDelay = Duration(milliseconds: i * 30);
|
||||
final animationDuration = const Duration(milliseconds: 1500);
|
||||
final Offset globalEndPos = Offset(random.nextDouble() * screenSize.width, -cardHeight - (AppBar().preferredSize.height));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('win_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: animationDuration,
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform.rotate(
|
||||
angle: value * pi * 2,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp=true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(animationDelay, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
|
||||
// if (i == 0) { ... }
|
||||
|
||||
Future.delayed(animationDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🔽 [추가] 딤 오버레이(배경 어두워짐)는 500ms 뒤에 바로 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
setState(() { _showDimOverlay = true; });
|
||||
}
|
||||
});
|
||||
|
||||
// 🔽 [추가] 랭킹 팝업은 약 2초 뒤 표시
|
||||
final popupDelay = (allCards.length > 70) ? const Duration(seconds: 2) : const Duration(milliseconds: 500);
|
||||
Future.delayed(popupDelay, () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
|
||||
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSpiderScore(int primary, int? secondary) {
|
||||
final moves = primary.toString();
|
||||
final time = (secondary ?? 0).toString();
|
||||
return '${moves}회 (${time}초)';
|
||||
}
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSpiderProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장)
|
||||
|
||||
final identityService = IdentityService();
|
||||
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SPIDER',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.currentState.moves,
|
||||
secondaryScore: controller.secondsElapsed,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSpiderScore,
|
||||
onProgressSave: saveSpiderProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ [삭제] _showGameCompletedDialog() 메서드 전체 (약 200줄) 삭제
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_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 'spider_game_screen.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
|
||||
class SpiderLobbyScreen extends StatefulWidget {
|
||||
const SpiderLobbyScreen({ super.key });
|
||||
@override
|
||||
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
bool _isLoading = false;
|
||||
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( _loadProgress 메서드는 이전과 동일 )
|
||||
Future<void> _loadProgress() async {
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
for (final level in SpiderDifficulties.allDifficulties) {
|
||||
rankFutures.add(_puzzleService.fetchRanks('SPIDER', level.contextId));
|
||||
}
|
||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
||||
Map<int, int> newRankMapForStorage = {};
|
||||
Map<int, (int, int)> newRankHistoryForState = {};
|
||||
for (int i = 0; i < SpiderDifficulties.allDifficulties.length; i++) {
|
||||
final level = SpiderDifficulties.allDifficulties[i];
|
||||
final currentRanks = allRankResults[i];
|
||||
final int levelIndex = level.levelIndex;
|
||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||
int currentRank = 0;
|
||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
||||
newRankMapForStorage[levelIndex] = currentRank;
|
||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||
}
|
||||
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'SPIDER');
|
||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
||||
log("스파이더 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
|
||||
Future<void> _startGame(SpiderDifficulty level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
|
||||
|
||||
// 2. 컨트롤러 생성 및 새 게임 시작
|
||||
final gameController = SpiderGameController();
|
||||
gameController.setUserInfo(userId, userName); // 👈 유저 정보 주입
|
||||
gameController.startNewGame(level);
|
||||
|
||||
setState(() { _isLoading = false; });
|
||||
if (!mounted) return;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChangeNotifierProvider.value(
|
||||
value: gameController,
|
||||
child: const SpiderGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( build 메서드는 이전과 동일 )
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CommonGameShell(
|
||||
title: '스파이더 솔리테어',
|
||||
onRankingPressed: () {
|
||||
final List<GameDifficulty> spiderDifficulties = SpiderDifficulties.allDifficulties
|
||||
.map((level) => GameDifficulty(
|
||||
name: level.name,
|
||||
contextId: level.contextId,
|
||||
))
|
||||
.toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SPIDER',
|
||||
difficulties: spiderDifficulties,
|
||||
initialDifficultyName: SpiderDifficulties.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: ListView.builder(
|
||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SpiderDifficulty level = SpiderDifficulties.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}위";
|
||||
if (oldRank > 0) {
|
||||
int change = oldRank - currentRank;
|
||||
if (change > 0) {
|
||||
subtitleText = "$rankStr (▲ $change)";
|
||||
subtitleColor = Colors.green;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
||||
} else if (change < 0) {
|
||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||
subtitleColor = Colors.red;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
||||
} else {
|
||||
subtitleText = "$rankStr (유지)";
|
||||
subtitleColor = Colors.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, 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