...
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import '../utils/image_pixel_analyzer.dart';
|
||||
import '../utils/text_bitmap_generator.dart';
|
||||
import '../models/tracing_difficulty.dart';
|
||||
|
||||
class TracingGameScreen extends BaseGameScreen {
|
||||
final int levelIndex;
|
||||
|
||||
const TracingGameScreen({
|
||||
super.key,
|
||||
super.onNextGame,
|
||||
this.levelIndex = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TracingGameScreen> createState() => _TracingGameScreenState();
|
||||
}
|
||||
|
||||
class _TracingGameScreenState extends BaseGameScreenState<TracingGameScreen> {
|
||||
final ImagePixelAnalyzer _analyzer = ImagePixelAnalyzer();
|
||||
|
||||
// 드로잉 상태
|
||||
List<List<Offset>> _strokes = [];
|
||||
List<Offset> _currentStroke = [];
|
||||
|
||||
// 게임 진행 상태
|
||||
bool _isLoaded = false;
|
||||
double _currentScore = 0.0;
|
||||
|
||||
// 라운드 관리
|
||||
int _currentRound = 1;
|
||||
int _totalRounds = 1;
|
||||
|
||||
// 콘텐츠 데이터
|
||||
ui.Image? _guideUiImage;
|
||||
String? _guideAssetPath;
|
||||
late TracingContent _currentContent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateTotalRounds(); // 1. 총 문제 수 계산
|
||||
_loadNewProblem(); // 2. 첫 번째 문제 로드
|
||||
}
|
||||
|
||||
// 🔽 [설정] 패널티 가중치 (추후 widget.levelIndex에 따라 조절 가능)
|
||||
// Lv 1~3: 0.3 (관대함)
|
||||
// Lv 4~6: 0.5 (보통)
|
||||
// Lv 7~: 0.8 (엄격)
|
||||
double get _penaltyWeight {
|
||||
if (widget.levelIndex <= 3) return 0.3;
|
||||
if (widget.levelIndex <= 6) return 0.5;
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
/// 난이도별 문제 수 설정
|
||||
void _calculateTotalRounds() {
|
||||
if (widget.levelIndex <= 3) {
|
||||
_totalRounds = 5; // 1~3단계: 5문제
|
||||
} else if (widget.levelIndex <= 6) {
|
||||
_totalRounds = 3; // 4~6단계: 3문제
|
||||
} else if (widget.levelIndex == 7) {
|
||||
_totalRounds = 2; // 7단계: 2문제
|
||||
} else {
|
||||
_totalRounds = 1; // 8단계 이상: 1문제
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로운 문제 로드 (초기화 + 콘텐츠 생성)
|
||||
Future<void> _loadNewProblem() async {
|
||||
setState(() {
|
||||
_isLoaded = false;
|
||||
_currentScore = 0.0;
|
||||
_strokes.clear();
|
||||
_analyzer.reset();
|
||||
});
|
||||
|
||||
// 레벨에 맞는 랜덤 콘텐츠 가져오기
|
||||
_currentContent = TracingDifficultyRepository.getContent(widget.levelIndex);
|
||||
|
||||
try {
|
||||
if (_currentContent.type == TracingContentType.text) {
|
||||
// [텍스트 모드] 비트맵 생성
|
||||
const Size canvasSize = Size(300, 300);
|
||||
final ui.Image bmp = await TextBitmapGenerator.generate(_currentContent.data, canvasSize);
|
||||
await _analyzer.loadFromUiImage(bmp);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_guideUiImage = bmp;
|
||||
_guideAssetPath = null;
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// [이미지 모드] 에셋 로드
|
||||
await _analyzer.loadFromAsset(_currentContent.data);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_guideUiImage = null;
|
||||
_guideAssetPath = _currentContent.data;
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("콘텐츠 로드 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_analyzer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 캔버스 지우기 (사용자 요청)
|
||||
void _clearCanvas() {
|
||||
setState(() {
|
||||
_strokes.clear();
|
||||
_currentScore = 0.0;
|
||||
});
|
||||
_analyzer.reset();
|
||||
}
|
||||
|
||||
// 🔽 [수정] 터치 종료 시 성공 체크
|
||||
void _onPanEnd(DragEndDetails details) {
|
||||
// 감점이 적용된 점수가 80점 이상이어야 성공!
|
||||
// 이제 막 칠하면 점수가 오히려 떨어져서 절대 성공 못 함
|
||||
if (_currentScore >= 0.80) {
|
||||
_handleCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [수정] 히트 체크 및 점수 갱신
|
||||
void _checkHit(Offset localPos) {
|
||||
if (!_isLoaded) return;
|
||||
|
||||
// 1. 히트 체크
|
||||
bool changed = _analyzer.checkHit(localPos, const Size(300, 300), touchSize: 20.0);
|
||||
|
||||
if (changed) {
|
||||
// 2. 점수 계산 시 가중치 전달
|
||||
double newScore = _analyzer.getScore(penaltyWeight: _penaltyWeight);
|
||||
|
||||
if (newScore != _currentScore) {
|
||||
setState(() => _currentScore = newScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 제스처 핸들러 ---
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_currentStroke = [details.localPosition];
|
||||
_strokes.add(_currentStroke);
|
||||
});
|
||||
_checkHit(details.localPosition);
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_currentStroke.add(details.localPosition);
|
||||
});
|
||||
_checkHit(details.localPosition);
|
||||
}
|
||||
|
||||
/// 성공 시 처리 로직 (다음 문제 vs 게임 종료)
|
||||
void _handleCompletion() {
|
||||
if (_currentRound < _totalRounds) {
|
||||
// 1. 아직 풀 문제가 남았을 때 -> 다음 문제 로드
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("성공! 다음 문제로 넘어갑니다. (${_currentRound + 1}/$_totalRounds)"),
|
||||
duration: const Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_currentRound++;
|
||||
});
|
||||
|
||||
// 잠시 대기 후 새 문제 로드 (자연스러운 전환)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_loadNewProblem();
|
||||
});
|
||||
|
||||
} else {
|
||||
// 2. 모든 문제를 다 풀었을 때 -> 게임 종료 (성공 팝업)
|
||||
showCommonGameCompletion(
|
||||
GameResultArgs(
|
||||
gameType: 'TRACING',
|
||||
contextId: 'Lv${widget.levelIndex}',
|
||||
primaryScore: 100,
|
||||
scoreFormatter: (s, _) => "훈련 완료!",
|
||||
|
||||
// 자동 저장 데이터
|
||||
levelIndex: widget.levelIndex,
|
||||
stars: 3,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// 앱바 제목에 진행 상황 표시
|
||||
title: Text('따라 그리기 ($_currentRound/$_totalRounds)'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '다시 쓰기',
|
||||
onPressed: _clearCanvas,
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
"${(_currentScore * 100).toInt()}%",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
_currentContent.type == TracingContentType.text
|
||||
? "'${_currentContent.data}' 를 따라 쓰세요."
|
||||
: "모양을 따라 그리세요.",
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
]
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// [Layer 1] 가이드
|
||||
if (_isLoaded)
|
||||
Opacity(
|
||||
opacity: 0.15, // 가시성 조절
|
||||
child: _currentContent.type == TracingContentType.text
|
||||
? RawImage(image: _guideUiImage, fit: BoxFit.contain)
|
||||
: Image.asset(_guideAssetPath!, fit: BoxFit.contain),
|
||||
)
|
||||
else
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
|
||||
// [Layer 2] 그리기 영역
|
||||
GestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
child: CustomPaint(
|
||||
painter: _StrokePainter(_strokes),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 안내 메시지
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40.0),
|
||||
child: Text(
|
||||
_currentRound < _totalRounds
|
||||
? "80% 이상 채우면 다음 문제로 넘어갑니다."
|
||||
: "마지막 문제입니다! 끝까지 집중하세요.",
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokePainter extends CustomPainter {
|
||||
final List<List<Offset>> strokes;
|
||||
_StrokePainter(this.strokes);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.blueAccent.withOpacity(0.6)
|
||||
..strokeWidth = 20.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.length < 2) continue;
|
||||
final path = Path();
|
||||
path.moveTo(stroke.first.dx, stroke.first.dy);
|
||||
for (int i = 1; i < stroke.length; i++) {
|
||||
path.lineTo(stroke[i].dx, stroke[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:feature_common/feature_common.dart'; // BaseLobbyScreen 가정 또는 직접 구현
|
||||
import 'tracing_game_screen.dart';
|
||||
|
||||
class TracingLobbyScreen extends StatelessWidget {
|
||||
const TracingLobbyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('따라 그리기')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.gesture, size: 80, color: Colors.blueAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"손가락으로 글씨나 그림을 따라 그리며\n시지각 능력과 소근육을 훈련합니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2, childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 16, mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) {
|
||||
final level = index + 1;
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.blueAccent,
|
||||
elevation: 2,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => TracingGameScreen(levelIndex: level)));
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("Lv. $level", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_getLevelDesc(level), style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getLevelDesc(int level) {
|
||||
switch(level) {
|
||||
case 1: return "자음/숫자";
|
||||
case 2: return "한 글자";
|
||||
case 3: return "알파벳";
|
||||
case 4: return "단어 (3자)";
|
||||
case 5: return "영어 단어";
|
||||
default: return "복합 단어";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user