This commit is contained in:
2025-12-15 18:18:17 +09:00
parent 03a7ed2ef2
commit 4c2c98de8a
216 changed files with 9831 additions and 725 deletions
@@ -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 "복합 단어";
}
}
}