...
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user