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,4 @@
library feature_game_tracing;
export 'screens/tracing_game_screen.dart';
export 'screens/tracing_lobby_screen.dart'; // 추가
@@ -0,0 +1,70 @@
import 'dart:math';
enum TracingContentType {
text, // 글자/단어 (생성기 사용)
image, // 이미지 (에셋 로드, 추후 사용)
}
class TracingContent {
final String data; // 텍스트 내용 또는 이미지 경로
final TracingContentType type;
TracingContent({required this.data, required this.type});
}
class TracingDifficultyRepository {
static final Random _random = Random();
// 난이도별 데이터 뱅크
static final Map<int, List<String>> _textLevels = {
// Level 1: ㄱㄴㄷ, 숫자 (단순 자모/숫자)
1: ['', '', '', '', '', '', '', '', '', '1', '2', '3', '4', '5', '7'],
// Level 2: 가, 나, 다 (한글 한 글자)
2: ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''],
// Level 3: 알파벳 한 글자
3: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'K', 'M', 'N', 'S', 'R', 'T'],
// Level 4: 3자 이하 한글 단어
4: ['사과', '포도', '나무', '바다', '하늘', '구름', '사랑', '친구', '가족', '대한민국', '무궁화'],
// Level 5: 5자 이하 영어 단어
5: ['CAT', 'DOG', 'BIRD', 'LOVE', 'MILK', 'BOOK', 'HAND', 'BLUE', 'ROSE', 'HAPPY'],
// Level 6: 복합 (한글 5자 이하 + 영어 8자 이하)
6: ['아이스크림', '코끼리', '바나나', '텔레비전', 'COMPUTER', 'ELEPHANT', 'MORNING', 'TEACHER', 'SUMMER'],
};
// 추후 이미지 레벨 (7, 8단계)
static final Map<int, List<String>> _imageLevels = {
7: ['assets/images/shapes/circle.png', 'assets/images/shapes/rect.png'], // 단순 도형
8: ['assets/images/shapes/star.png', 'assets/images/shapes/heart.png'], // 복잡 도형
};
/// 해당 레벨의 랜덤 콘텐츠 반환
static TracingContent getContent(int level) {
// 7단계 이상은 이미지 모드로 처리 (준비된 경우)
if (level >= 7) {
// 이미지가 준비되지 않았으면 6단계 텍스트로 대체 (안전장치)
if (!_imageLevels.containsKey(level)) return getContent(6);
final list = _imageLevels[level]!;
return TracingContent(
data: list[_random.nextInt(list.length)],
type: TracingContentType.image,
);
}
// 1~6단계 텍스트 모드
else {
// 레벨 범위를 벗어나면 최대 레벨로 고정
int targetLevel = level.clamp(1, 6);
final list = _textLevels[targetLevel]!;
return TracingContent(
data: list[_random.nextInt(list.length)],
type: TracingContentType.text,
);
}
}
}
@@ -0,0 +1,49 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class TracingPainter extends CustomPainter {
final List<Offset> userPath; // 사용자가 그린 선
final List<Offset> targetPath; // 목표 가이드 선 (점들의 집합)
TracingPainter({required this.userPath, required this.targetPath});
@override
void paint(Canvas canvas, Size size) {
// 1. 목표 가이드 라인 그리기 (회색 점선 스타일)
final guidePaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 24.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// 점들을 연결해서 경로 생성
if (targetPath.isNotEmpty) {
final path = Path()..moveTo(targetPath.first.dx, targetPath.first.dy);
for (int i = 1; i < targetPath.length; i++) {
path.lineTo(targetPath[i].dx, targetPath[i].dy);
}
// 닫힌 도형이면 마지막 점과 첫 점 연결 (필요시 옵션 처리)
// path.close();
canvas.drawPath(path, guidePaint);
}
// 2. 사용자가 그리는 선 (파란색 실선)
final userPaint = Paint()
..color = Colors.blueAccent.withOpacity(0.8)
..style = PaintingStyle.stroke
..strokeWidth = 16.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
if (userPath.isNotEmpty) {
// 포인트가 너무 많으면 성능 저하가 올 수 있으므로 PointMode.polygon 사용
canvas.drawPoints(PointMode.polygon, userPath, userPaint);
}
}
@override
bool shouldRepaint(covariant TracingPainter oldDelegate) {
return true; // 실시간으로 계속 다시 그려야 함
}
}
@@ -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 "복합 단어";
}
}
}
@@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
class ImagePixelAnalyzer {
ByteData? _byteData;
int _width = 0;
int _height = 0;
final Set<int> _hitPixels = {}; // 맞춘 픽셀 (Target)
final Set<int> _wrongPixels = {}; // 틀린 픽셀 (Background)
int _totalTargetPixels = 0;
bool get isLoaded => _byteData != null;
int get width => _width;
int get height => _height;
// ... (loadFromAsset, loadFromUiImage는 기존과 동일) ...
Future<void> loadFromAsset(String assetPath) async {
final ByteData data = await rootBundle.load(assetPath);
final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
final ui.FrameInfo fi = await codec.getNextFrame();
await loadFromUiImage(fi.image);
}
Future<void> loadFromUiImage(ui.Image image) async {
_width = image.width;
_height = image.height;
_byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
_calculateTargetPixels();
}
void _calculateTargetPixels() {
_totalTargetPixels = 0;
_hitPixels.clear();
_wrongPixels.clear();
if (_byteData == null) return;
for (int i = 0; i < _byteData!.lengthInBytes; i += 4) {
final int alpha = _byteData!.getUint8(i + 3);
if (alpha > 50) {
_totalTargetPixels++;
}
}
}
void reset() {
_hitPixels.clear();
_wrongPixels.clear();
}
/// 🔽 [핵심 수정] 터치 판정 로직 개선
/// 선에 '조금이라도' 닿아 있으면 빗나간 부분을 용서해줍니다.
bool checkHit(Offset position, Size displaySize, {double touchSize = 15.0}) {
if (_byteData == null) return false;
final double scaleX = _width / displaySize.width;
final double scaleY = _height / displaySize.height;
final int imgX = (position.dx * scaleX).round();
final int imgY = (position.dy * scaleY).round();
final int radius = (touchSize * scaleX).round();
// 이번 터치 이벤트에서 발견된 픽셀들 임시 저장
List<int> currentHits = [];
List<int> currentWrongs = [];
for (int x = imgX - radius; x <= imgX + radius; x++) {
for (int y = imgY - radius; y <= imgY + radius; y++) {
if (x < 0 || x >= _width || y < 0 || y >= _height) continue;
if ((x - imgX) * (x - imgX) + (y - imgY) * (y - imgY) > radius * radius) continue;
final int pixelIndex = (y * _width + x) * 4;
final int alpha = _byteData!.getUint8(pixelIndex + 3);
if (alpha > 50) {
// 목표 영역임
if (!_hitPixels.contains(pixelIndex)) {
currentHits.add(pixelIndex);
}
} else {
// 빈 공간임
if (!_wrongPixels.contains(pixelIndex)) {
currentWrongs.add(pixelIndex);
}
}
}
}
bool hasChange = false;
// [판정 로직]
// 1. 이번 터치 범위 내에 '목표 픽셀'이 하나라도 있었는가?
bool touchedTarget = currentHits.isNotEmpty;
// 이미 방문했던 곳이라 currentHits가 0일 수도 있으니,
// 주변에 방문한 _hitPixels가 있는지도 체크하면 더 좋지만,
// 일단은 "새로 칠한 Hit가 있거나" OR "중심점이 Target 위에 있는지"로 판단
if (!touchedTarget) {
// 중심점 체크 (이미 칠한 곳 위를 지나갈 때 감점 방지)
int centerIndex = (imgY * _width + imgX) * 4;
if (centerIndex >= 0 && centerIndex < _byteData!.lengthInBytes) {
if (_byteData!.getUint8(centerIndex + 3) > 50) {
touchedTarget = true;
}
}
}
if (touchedTarget) {
// ✅ 선 위를 지나가는 중 -> Hit만 인정, Wrong(삐져나간 것)은 무시!
if (currentHits.isNotEmpty) {
_hitPixels.addAll(currentHits);
hasChange = true;
}
// currentWrongs는 추가하지 않음 (관대함)
} else {
// ❌ 선과 전혀 상관없는 허공을 칠함 -> Wrong 모두 기록 (감점)
if (currentWrongs.isNotEmpty) {
_wrongPixels.addAll(currentWrongs);
hasChange = true;
}
}
return hasChange;
}
/// 🔽 [수정] 가중치(penaltyWeight)를 외부에서 받도록 변경
double getScore({double penaltyWeight = 0.3}) {
if (_totalTargetPixels == 0) return 0.0;
double rawScore = _hitPixels.length.toDouble();
double penalty = _wrongPixels.length * penaltyWeight;
// 점수 = (성공 - 패널티) / 전체
double finalScore = (rawScore - penalty) / _totalTargetPixels;
return finalScore.clamp(0.0, 1.0);
}
void dispose() {
_byteData = null;
_hitPixels.clear();
_wrongPixels.clear();
}
}
@@ -0,0 +1,55 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class TextBitmapGenerator {
static Future<ui.Image> generate(String text, Size size) async {
final ui.PictureRecorder recorder = ui.PictureRecorder();
final Canvas canvas = Canvas(recorder);
canvas.drawColor(Colors.transparent, BlendMode.clear);
// 🔽 [수정] 폰트 크기 자동 조절 (Auto-fit)
// 기본 크기: 높이의 75%
double fontSize = size.height * 0.75;
// 글자가 길어지면 너비에 맞춰 줄임
// (대략적인 계산: 한 글자당 너비가 fontSize * 0.6 ~ 1.0 정도 차지함)
if (text.length > 1) {
// 화면 너비를 글자 수로 나눈 것과 비교하여 더 작은 값 선택
// 여유를 두기 위해 0.9 곱함
double maxWidthPerChar = (size.width / text.length) * 1.3;
if (fontSize > maxWidthPerChar) {
fontSize = maxWidthPerChar;
}
}
final TextSpan span = TextSpan(
style: TextStyle(
color: Colors.black,
fontSize: fontSize,
fontWeight: FontWeight.bold,
fontFamily: 'Roboto', // 한글 지원 폰트 권장
),
text: text,
);
final TextPainter tp = TextPainter(
text: span,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
tp.layout(minWidth: size.width, maxWidth: size.width);
// 중앙 정렬
final Offset textOffset = Offset(
(size.width - tp.width) / 2,
(size.height - tp.height) / 2,
);
tp.paint(canvas, textOffset);
final ui.Picture picture = recorder.endRecording();
return await picture.toImage(size.width.toInt(), size.height.toInt());
}
}