🎮 먼저 테트리스 전체 소스입니다.
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/sound_service.dart';
// ─── 상수 ───────────────────────────────────────────────────
const int kCols = 10;
const int kRows = 20;
const String kBestScoreKey = 'tetris_best_score';
// ─── 테트로미노 정의 (상대 셀 좌표) ─────────────────────────
const List<List<List<int>>> kPieces = [
[[0,1],[1,1],[2,1],[3,1]], // I
[[0,0],[1,0],[0,1],[1,1]], // O
[[0,1],[1,1],[2,1],[1,0]], // T
[[1,0],[2,0],[0,1],[1,1]], // S
[[0,0],[1,0],[1,1],[2,1]], // Z
[[0,0],[0,1],[1,1],[2,1]], // J
[[2,0],[0,1],[1,1],[2,1]], // L
];
const List<Color> kColors = [
Color(0xFF00CFCF), // I
Color(0xFFCFCF00), // O
Color(0xFFAF00CF), // T
Color(0xFF00CF00), // S
Color(0xFFCF0000), // Z
Color(0xFF0000CF), // J
Color(0xFFCF6E00), // L
];
// ─── 피스 클래스 ─────────────────────────────────────────────
class Piece {
int type, x, y;
List<List<int>> cells;
Piece({required this.type, required this.x, required this.y, required this.cells});
factory Piece.spawn(int type) {
final cells = kPieces[type].map((c) => List<int>.from(c)).toList();
return Piece(type: type, x: kCols ~/ 2 - 2, y: 0, cells: cells);
}
List<List<int>> absoluteCells() =>
cells.map((c) => [c[0] + x, c[1] + y]).toList();
Piece rotated() {
if (type == 1) return copyWith();
final rotCells = cells.map((c) => [-c[1], c[0]]).toList();
final minX = rotCells.map((c) => c[0]).reduce(min);
final minY = rotCells.map((c) => c[1]).reduce(min);
final norm = rotCells.map((c) => [c[0] - minX, c[1] - minY]).toList();
return Piece(type: type, x: x, y: y, cells: norm);
}
Piece copyWith({int? x, int? y, List<List<int>>? cells}) =>
Piece(
type: type,
x: x ?? this.x,
y: y ?? this.y,
cells: cells ?? this.cells.map((c) => List<int>.from(c)).toList(),
);
}
// ─── 게임 로직 ───────────────────────────────────────────────
class TetrisGame {
List<List<int>> board = List.generate(kRows, (_) => List.filled(kCols, -1));
late Piece current;
late Piece next;
int score = 0;
int level = 1;
int lines = 0;
bool isOver = false;
bool isPaused = false;
final Random _rng = Random();
TetrisGame() {
current = Piece.spawn(_rng.nextInt(kPieces.length));
next = Piece.spawn(_rng.nextInt(kPieces.length));
}
bool _collides(Piece p) {
for (final c in p.absoluteCells()) {
if (c[0] < 0 || c[0] >= kCols || c[1] >= kRows) return true;
if (c[1] >= 0 && board[c[1]][c[0]] != -1) return true;
}
return false;
}
void moveLeft() {
if (isOver || isPaused) return;
final moved = current.copyWith(x: current.x - 1);
if (!_collides(moved)) {
current = moved;
SoundService().playMove();
}
}
void moveRight() {
if (isOver || isPaused) return;
final moved = current.copyWith(x: current.x + 1);
if (!_collides(moved)) {
current = moved;
SoundService().playMove();
}
}
bool moveDown() {
if (isOver || isPaused) return false;
final moved = current.copyWith(y: current.y + 1);
if (!_collides(moved)) { current = moved; return true; }
_lock();
return false;
}
void rotate() {
if (isOver || isPaused) return;
final rotated = current.rotated();
for (final dx in [0, -1, 1, -2, 2]) {
final kicked = rotated.copyWith(x: rotated.x + dx);
if (!_collides(kicked)) {
current = kicked;
SoundService().playRotate();
return;
}
}
}
void hardDrop() {
if (isOver || isPaused) return;
while (moveDown()) {}
}
void _lock() {
for (final c in current.absoluteCells()) {
if (c[1] < 0) { isOver = true; return; }
board[c[1]][c[0]] = current.type;
}
_clearLines();
current = next;
next = Piece.spawn(_rng.nextInt(kPieces.length));
if (_collides(current)) {
isOver = true;
} else {
SoundService().playLand();
}
}
void _clearLines() {
final full = <int>[];
for (int r = 0; r < kRows; r++) {
if (board[r].every((c) => c != -1)) full.add(r);
}
if (full.isEmpty) return;
SoundService().playClear();
for (final r in full) {
board.removeAt(r);
board.insert(0, List.filled(kCols, -1));
}
lines += full.length;
level = 1 + lines ~/ 10;
const pts = [0, 100, 300, 500, 800];
score += pts[min(full.length, 4)] * level;
}
Piece get ghost {
var g = current.copyWith();
while (true) {
final down = g.copyWith(y: g.y + 1);
if (_collides(down)) break;
g = down;
}
return g;
}
int get dropInterval => max(100, 800 - (level - 1) * 70);
}
// ─── 파티클 (축하 연출용) ────────────────────────────────────
class _Particle {
double x, y, vx, vy, size, opacity;
Color color;
_Particle({required this.x, required this.y, required this.vx,
required this.vy, required this.size, required this.opacity, required this.color});
}
// ─── 화면 위젯 ───────────────────────────────────────────────
class TetrisScreen extends StatefulWidget {
const TetrisScreen({super.key});
@override
State<TetrisScreen> createState() => _TetrisScreenState();
}
class _TetrisScreenState extends State<TetrisScreen>
with TickerProviderStateMixin {
late TetrisGame _game;
Timer? _gameTimer;
int _bestScore = 0;
bool _isNewRecord = false;
// 파티클
final List<_Particle> _particles = [];
Timer? _particleTimer;
final Random _rng = Random();
// 축하 배너 애니메이션
late AnimationController _bannerCtrl;
late Animation<double> _bannerAnim;
// 점수 플래시 애니메이션
late AnimationController _flashCtrl;
late Animation<double> _flashAnim;
double _dragX = 0, _dragY = 0;
@override
void initState() {
super.initState();
_bannerCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600));
_bannerAnim = CurvedAnimation(parent: _bannerCtrl, curve: Curves.elasticOut);
_flashCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
_flashAnim = CurvedAnimation(parent: _flashCtrl, curve: Curves.easeOut);
_loadBestScore();
_game = TetrisGame();
_startGameTimer();
}
Future<void> _loadBestScore() async {
final prefs = await SharedPreferences.getInstance();
setState(() => _bestScore = prefs.getInt(kBestScoreKey) ?? 0);
}
Future<void> _saveBestScore(int score) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(kBestScoreKey, score);
}
void _startGameTimer() {
_gameTimer?.cancel();
_gameTimer = Timer.periodic(Duration(milliseconds: _game.dropInterval), (_) {
if (_game.isPaused || _game.isOver) return;
final prevScore = _game.score;
setState(() => _game.moveDown());
// 레벨 변경 시 타이머 갱신
if (_game.dropInterval != (max(100, 800 - (_game.level - 2) * 70))) {
_startGameTimer();
}
// 점수 갱신 확인 → 신기록 체크
if (_game.score != prevScore) {
_checkNewRecord();
}
// 게임 오버 처리
if (_game.isOver) {
SoundService().playGameOver();
_handleGameOver();
}
});
}
void _checkNewRecord() {
if (_game.score > _bestScore) {
if (!_isNewRecord) {
// 신기록 처음 달성 순간
_isNewRecord = true;
_triggerNewRecordEffect();
}
}
}
void _triggerNewRecordEffect() {
_bannerCtrl.forward(from: 0);
_flashCtrl.forward(from: 0).then((_) => _flashCtrl.reverse());
SoundService().playNewRecord();
_spawnParticles();
}
void _spawnParticles() {
_particles.clear();
final colors = [Colors.yellowAccent, Colors.pinkAccent, Colors.cyanAccent,
Colors.greenAccent, Colors.orangeAccent, Colors.purpleAccent];
for (int i = 0; i < 60; i++) {
_particles.add(_Particle(
x: _rng.nextDouble(),
y: -0.05,
vx: (_rng.nextDouble() - 0.5) * 0.008,
vy: _rng.nextDouble() * 0.012 + 0.004,
size: _rng.nextDouble() * 8 + 4,
opacity: 1.0,
color: colors[_rng.nextInt(colors.length)],
));
}
_particleTimer?.cancel();
_particleTimer = Timer.periodic(const Duration(milliseconds: 30), (t) {
setState(() {
for (final p in _particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.0003; // 중력
p.opacity -= 0.012;
}
_particles.removeWhere((p) => p.opacity <= 0 || p.y > 1.2);
});
if (_particles.isEmpty) t.cancel();
});
}
void _handleGameOver() {
_gameTimer?.cancel();
if (_game.score > _bestScore) {
_bestScore = _game.score;
_saveBestScore(_bestScore);
if (!_isNewRecord) {
_isNewRecord = true;
_triggerNewRecordEffect();
}
}
}
void _restart() {
_gameTimer?.cancel();
_particleTimer?.cancel();
_particles.clear();
_bannerCtrl.reset();
setState(() {
_game = TetrisGame();
_isNewRecord = false;
});
_startGameTimer();
}
void _showRestartDialog() {
final bool wasPaused = _game.isPaused;
setState(() => _game.isPaused = true);
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('게임 재시작'),
content: const Text('게임을 다시 시작할까요?'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
setState(() => _game.isPaused = wasPaused);
},
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_restart();
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.deepPurple),
child: const Text('재시작', style: TextStyle(color: Colors.white)),
),
],
),
);
}
void _togglePause() => setState(() => _game.isPaused = !_game.isPaused);
@override
void dispose() {
_gameTimer?.cancel();
_particleTimer?.cancel();
_bannerCtrl.dispose();
_flashCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D1A),
appBar: AppBar(
backgroundColor: const Color(0xFF1A1A2E),
foregroundColor: Colors.white,
title: const Text('🎮 MINI GAME',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 3)),
centerTitle: true,
actions: [
IconButton(
icon: Icon(_game.isPaused ? Icons.play_arrow : Icons.pause),
onPressed: _togglePause,
),
IconButton(icon: const Icon(Icons.refresh), onPressed: _showRestartDialog),
],
),
body: SafeArea(
child: LayoutBuilder(builder: (context, constraints) {
return GestureDetector(
onHorizontalDragStart: (d) => _dragX = d.localPosition.dx,
onHorizontalDragUpdate: (d) {
final diff = d.localPosition.dx - _dragX;
if (diff.abs() > 20) {
setState(() {
if (diff < 0) _game.moveLeft(); else _game.moveRight();
});
_dragX = d.localPosition.dx;
}
},
onVerticalDragStart: (d) => _dragY = d.localPosition.dy,
onVerticalDragUpdate: (d) {
final diff = d.localPosition.dy - _dragY;
if (diff > 20) {
setState(() => _game.moveDown());
_dragY = d.localPosition.dy;
}
},
onDoubleTap: () => setState(() => _game.hardDrop()),
onTap: () => setState(() => _game.rotate()),
child: Stack(
children: [
Column(children: [
Expanded(child: _buildGameArea()),
_buildControls(),
]),
// 파티클 레이어
if (_particles.isNotEmpty)
Positioned.fill(
child: CustomPaint(
painter: _ParticlePainter(_particles),
),
),
// 신기록 배너
if (_isNewRecord)
Positioned(
top: 12,
left: 0,
right: 0,
child: ScaleTransition(
scale: _bannerAnim,
child: _buildNewRecordBanner(),
),
),
// 점수 플래시
AnimatedBuilder(
animation: _flashAnim,
builder: (_, __) {
final v = _flashAnim.value;
if (v == 0) return const SizedBox.shrink();
return Positioned.fill(
child: IgnorePointer(
child: Container(
color: Colors.yellowAccent.withOpacity(v * 0.12),
),
),
);
},
),
],
),
);
}),
),
);
}
Widget _buildNewRecordBanner() {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFFD700), Color(0xFFFF8C00), Color(0xFFFFD700)],
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(color: Colors.orange.withOpacity(0.7), blurRadius: 20, spreadRadius: 4),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Text('🏆', style: TextStyle(fontSize: 22)),
SizedBox(width: 8),
Text(
'NEW RECORD!',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
letterSpacing: 3,
shadows: [Shadow(color: Colors.orange, blurRadius: 8)],
),
),
SizedBox(width: 8),
Text('🏆', style: TextStyle(fontSize: 22)),
],
),
),
);
}
Widget _buildGameArea() {
return LayoutBuilder(builder: (context, constraints) {
final cellSize = min(
constraints.maxWidth * 0.63 / kCols,
constraints.maxHeight / kRows,
);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Stack(children: [
_buildBoard(cellSize, cellSize * kCols, cellSize * kRows),
if (_game.isOver)
_buildGameOver(cellSize * kCols, cellSize * kRows),
if (_game.isPaused && !_game.isOver)
_buildPaused(cellSize * kCols, cellSize * kRows),
]),
const SizedBox(width: 12),
_buildSidePanel(cellSize),
],
);
});
}
Widget _buildBoard(double cellSize, double boardW, double boardH) {
final ghostCells = _game.ghost.absoluteCells();
final currentCells = _game.current.absoluteCells();
return Container(
width: boardW,
height: boardH,
decoration: BoxDecoration(
color: const Color(0xFF0A0A14),
border: Border.all(color: Colors.deepPurple.shade700, width: 2),
boxShadow: [BoxShadow(
color: Colors.deepPurple.withOpacity(0.4), blurRadius: 20, spreadRadius: 2)],
),
child: CustomPaint(
painter: _BoardPainter(
board: _game.board,
currentCells: currentCells,
ghostCells: ghostCells,
currentType: _game.current.type,
cellSize: cellSize,
),
),
);
}
Widget _buildGameOver(double w, double h) {
final isNew = _game.score > 0 && _game.score >= _bestScore && _isNewRecord;
return Container(
width: w,
height: h,
color: Colors.black.withOpacity(0.78),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isNew) ...[
const Text('🏆', style: TextStyle(fontSize: 48)),
const SizedBox(height: 6),
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Color(0xFFFFD700), Color(0xFFFF8C00)],
).createShader(bounds),
child: const Text(
'신기록 달성!',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
),
const SizedBox(height: 4),
] else ...[
const Text('GAME OVER',
style: TextStyle(color: Colors.redAccent, fontSize: 24,
fontWeight: FontWeight.bold, letterSpacing: 2)),
const SizedBox(height: 8),
],
Text('점수: ${_game.score}',
style: const TextStyle(color: Colors.white, fontSize: 18,
fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('최고 기록: $_bestScore',
style: TextStyle(
color: isNew ? Colors.yellowAccent : Colors.white60,
fontSize: 14,
fontWeight: isNew ? FontWeight.bold : FontWeight.normal,
)),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _restart,
style: ElevatedButton.styleFrom(
backgroundColor: isNew ? Colors.orange : Colors.deepPurple,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
child: const Text('다시 시작', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _buildPaused(double w, double h) {
return Container(
width: w,
height: h,
color: Colors.black.withOpacity(0.6),
child: const Center(
child: Text('PAUSED',
style: TextStyle(color: Colors.white70, fontSize: 28,
fontWeight: FontWeight.bold, letterSpacing: 4)),
),
);
}
Widget _buildSidePanel(double cellSize) {
final nextCells = _game.next.absoluteCells();
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sideLabel('NEXT'),
const SizedBox(height: 6),
Container(
width: cellSize * 4,
height: cellSize * 4,
decoration: BoxDecoration(
color: const Color(0xFF0A0A14),
border: Border.all(color: Colors.deepPurple.shade700),
borderRadius: BorderRadius.circular(6),
),
child: CustomPaint(
painter: _NextPiecePainter(
cells: nextCells,
type: _game.next.type,
cellSize: cellSize,
offsetX: _game.next.x,
offsetY: _game.next.y,
),
),
),
const SizedBox(height: 14),
_sideLabel('SCORE'),
const SizedBox(height: 4),
Text('${_game.score}',
style: TextStyle(
color: _isNewRecord ? Colors.yellowAccent : Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
)),
const SizedBox(height: 10),
// 최고 기록
_sideLabel('BEST'),
const SizedBox(height: 4),
Row(children: [
if (_isNewRecord)
const Text('👑 ', style: TextStyle(fontSize: 13)),
Text(
'$_bestScore',
style: TextStyle(
color: _isNewRecord ? Colors.yellowAccent : Colors.white60,
fontSize: _isNewRecord ? 18 : 16,
fontWeight: _isNewRecord ? FontWeight.bold : FontWeight.normal,
),
),
]),
const SizedBox(height: 10),
_sideLabel('LEVEL'),
const SizedBox(height: 4),
_sideValue('${_game.level}'),
const SizedBox(height: 10),
_sideLabel('LINES'),
const SizedBox(height: 4),
_sideValue('${_game.lines}'),
],
);
}
Widget _sideLabel(String t) => Text(t,
style: const TextStyle(color: Colors.deepPurple, fontSize: 11,
fontWeight: FontWeight.bold, letterSpacing: 2));
Widget _sideValue(String t) => Text(t,
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold));
Widget _buildControls() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: const Color(0xFF1A1A2E),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ctrlBtn(Icons.arrow_left, () => setState(() => _game.moveLeft())),
_ctrlBtn(Icons.rotate_right, () => setState(() => _game.rotate())),
_ctrlBtn(Icons.arrow_drop_down_circle,
() => setState(() => _game.hardDrop()), color: Colors.redAccent),
_ctrlBtn(Icons.arrow_right, () => setState(() => _game.moveRight())),
_ctrlBtn(Icons.arrow_downward, () => setState(() => _game.moveDown())),
],
),
);
}
Widget _ctrlBtn(IconData icon, VoidCallback onTap, {Color? color}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 52, height: 52,
decoration: BoxDecoration(
color: (color ?? Colors.deepPurple).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: (color ?? Colors.deepPurple).withOpacity(0.6)),
),
child: Icon(icon, color: color ?? Colors.deepPurple.shade200, size: 28),
),
);
}
}
// ─── 파티클 Painter ───────────────────────────────────────────
class _ParticlePainter extends CustomPainter {
final List<_Particle> particles;
_ParticlePainter(this.particles);
@override
void paint(Canvas canvas, Size size) {
for (final p in particles) {
final paint = Paint()
..color = p.color.withOpacity(p.opacity.clamp(0, 1))
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2);
canvas.drawCircle(
Offset(p.x * size.width, p.y * size.height), p.size, paint);
}
}
@override
bool shouldRepaint(covariant _ParticlePainter old) => true;
}
// ─── Board Painter ────────────────────────────────────────────
class _BoardPainter extends CustomPainter {
final List<List<int>> board;
final List<List<int>> currentCells;
final List<List<int>> ghostCells;
final int currentType;
final double cellSize;
_BoardPainter({
required this.board, required this.currentCells,
required this.ghostCells, required this.currentType, required this.cellSize,
});
@override
void paint(Canvas canvas, Size size) {
final gridPaint = Paint()
..color = Colors.white.withOpacity(0.04)
..style = PaintingStyle.stroke
..strokeWidth = 0.5;
for (int c = 0; c <= kCols; c++) {
canvas.drawLine(Offset(c * cellSize, 0), Offset(c * cellSize, size.height), gridPaint);
}
for (int r = 0; r <= kRows; r++) {
canvas.drawLine(Offset(0, r * cellSize), Offset(size.width, r * cellSize), gridPaint);
}
for (int r = 0; r < kRows; r++) {
for (int c = 0; c < kCols; c++) {
if (board[r][c] != -1) _drawCell(canvas, c, r, kColors[board[r][c]]);
}
}
for (final c in ghostCells) {
if (c[1] >= 0 && c[1] < kRows && c[0] >= 0 && c[0] < kCols) {
_drawGhostCell(canvas, c[0], c[1], kColors[currentType]);
}
}
for (final c in currentCells) {
if (c[1] >= 0 && c[1] < kRows && c[0] >= 0 && c[0] < kCols) {
_drawCell(canvas, c[0], c[1], kColors[currentType]);
}
}
}
void _drawCell(Canvas canvas, int col, int row, Color color) {
final rect = Rect.fromLTWH(col * cellSize + 1, row * cellSize + 1, cellSize - 2, cellSize - 2);
final paint = Paint()
..shader = LinearGradient(
begin: Alignment.topLeft, end: Alignment.bottomRight,
colors: [color.withOpacity(0.9), color.withOpacity(0.5)],
).createShader(rect);
canvas.drawRRect(RRect.fromRectAndRadius(rect, const Radius.circular(2)), paint);
final hl = Paint()
..color = Colors.white.withOpacity(0.25)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(col * cellSize + 1, row * cellSize + 1, cellSize * 0.5, cellSize * 0.4),
const Radius.circular(2)),
hl);
}
void _drawGhostCell(Canvas canvas, int col, int row, Color color) {
final rect = Rect.fromLTWH(col * cellSize + 1, row * cellSize + 1, cellSize - 2, cellSize - 2);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(2)),
Paint()..color = color.withOpacity(0.18));
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(2)),
Paint()..color = color.withOpacity(0.45)..style = PaintingStyle.stroke..strokeWidth = 1);
}
@override
bool shouldRepaint(covariant _BoardPainter old) => true;
}
// ─── Next Piece Painter ───────────────────────────────────────
class _NextPiecePainter extends CustomPainter {
final List<List<int>> cells;
final int type;
final double cellSize;
final int offsetX, offsetY;
_NextPiecePainter({
required this.cells, required this.type,
required this.cellSize, required this.offsetX, required this.offsetY,
});
@override
void paint(Canvas canvas, Size size) {
for (final c in cells) {
final col = c[0] - offsetX;
final row = c[1] - offsetY;
if (col < 0 || col >= 4 || row < 0 || row >= 4) continue;
final rect = Rect.fromLTWH(col * cellSize + 1, row * cellSize + 1, cellSize - 2, cellSize - 2);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(2)),
Paint()..shader = LinearGradient(
colors: [kColors[type].withOpacity(0.9), kColors[type].withOpacity(0.5)],
).createShader(rect));
}
}
@override
bool shouldRepaint(covariant _NextPiecePainter old) => true;
}
🎮 게임 로직 (TetrisGame)
- 보드 정의: board는 20행 × 10열의 2차원 리스트로, 빈 칸은 -1로 표시됩니다.
- 피스 관리: Piece 클래스가 테트로미노 블록을 정의합니다.
- spawn()으로 새 블록 생성
- rotated()로 회전 처리 (O 블록은 회전 없음)
- absoluteCells()로 현재 위치 좌표 반환
- 충돌 검사: _collides() 함수로 벽, 바닥, 다른 블록과의 충돌 여부 확인.
- 이동/회전: moveLeft(), moveRight(), moveDown(), rotate() 등으로 조작.
- 하드드롭: hardDrop()은 블록을 끝까지 떨어뜨림.
- 라인 제거: _clearLines()에서 가득 찬 줄을 삭제하고 점수/레벨 갱신.
- 게임 오버 처리: 블록이 보드 상단에 닿으면 isOver = true.
🖼️ 화면 UI (TetrisScreen)
- 게임 영역: _buildBoard()에서 현재 보드와 블록을 그립니다.
- ghost 블록(예상 착지 위치)도 표시.
- 사이드 패널: 다음 블록, 점수, 최고 기록, 레벨, 라인 수 표시.
- 컨트롤 버튼: 좌/우 이동, 회전, 하드드롭, 아래 이동 버튼 제공.
- 제스처 입력: 드래그·탭·더블탭으로 조작 가능.
✨ 효과 및 애니메이션
- 신기록 배너: 점수가 최고 기록을 갱신하면 "NEW RECORD!" 배너와 파티클 효과 표시.
- 파티클 효과: _Particle과 _ParticlePainter로 축하 불꽃 같은 연출.
- 점수 플래시: 점수 갱신 시 화면에 반짝임 효과.
- 게임 오버 화면: "GAME OVER" 또는 "신기록 달성!" 메시지와 다시 시작 버튼 표시.
📌 특징 요약
- 게임성: 기본 테트리스 규칙 충실 구현 (회전, 라인 제거, 점수/레벨 시스템).
- UI/UX: 터치 제스처와 버튼 모두 지원 → 모바일 친화적.
- 효과: 신기록 달성 시 화려한 애니메이션과 사운드 효과.
- 데이터 저장: SharedPreferences로 최고 점수 영구 저장.
🏆 게임 점수 계산 방식
- 점수는 라인 제거 개수와 현재 레벨에 따라 달라집니다.
- _clearLines() 함수에서 처리:
- 한 번에 제거한 줄 수(full.length)에 따라 점수 테이블 적용:
- 1줄 제거 → 100점 × 현재 레벨
- 2줄 제거 → 300점 × 현재 레벨
- 3줄 제거 → 500점 × 현재 레벨
- 4줄 제거(테트리스) → 800점 × 현재 레벨
-
dart
const pts = [0, 100, 300, 500, 800]; - 예: 레벨 3에서 2줄 제거 → 300 × 3 = 900점
- 한 번에 제거한 줄 수(full.length)에 따라 점수 테이블 적용:
- 레벨은 lines ~/ 10 기준으로 올라갑니다. 즉, 10줄 제거할 때마다 레벨 +1.
- 레벨이 올라가면 블록 낙하 속도(dropInterval)가 빨라져 난이도가 상승합니다.
🔄 블록 회전 로직
- Piece.rotated() 함수에서 처리:
- 각 셀 좌표 (x, y)를 90도 회전: [-y, x]
- 회전 후 최소 좌표를 찾아서 정규화(normalization) → 보드 안에 맞게 위치 조정.
- O 블록(type == 1)은 회전하지 않음.
- 벽킥(Wall Kick): 회전 후 벽이나 다른 블록과 충돌하면, dx를 -1, +1, -2, +2로 이동시켜 충돌을 피할 수 있는지 검사.
- 성공하면 해당 위치로 회전 적용.
- 이 방식은 기본적인 SRS(Super Rotation System)보다는 단순화된 형태지만, 벽에 붙어 있어도 회전이 가능하게 설계되어 있습니다.
✨ UI 애니메이션 처리
UI는 Flutter의 AnimationController와 CustomPainter를 활용해 다양한 효과를 구현합니다.
- 신기록 배너 애니메이션
- _bannerCtrl + CurvedAnimation(Curves.elasticOut)
- "NEW RECORD!" 배너가 튀어나오는 듯한 효과.
- 점수 플래시 애니메이션
- _flashCtrl + Curves.easeOut
- 점수 갱신 시 화면 전체가 노란빛으로 반짝임.
- 파티클 효과
- _Particle 객체에 위치, 속도, 크기, 색상, 투명도 정의.
- _ParticlePainter에서 원형 파티클을 그려 축하 불꽃처럼 흩날리게 함.
- 중력(vy += 0.0003)과 투명도 감소(opacity -= 0.012)로 자연스러운 소멸 연출.
📌 요약
- 점수는 라인 제거 개수 × 레벨에 따라 계산.
- 블록 회전은 좌표 변환 + 벽킥으로 처리.
- UI는 애니메이션 컨트롤러 + 커스텀 페인터로 신기록 배너, 점수 플래시, 파티클 효과를 구현.
'Flutter' 카테고리의 다른 글
| Flutter 사용 기본사항 (0) | 2026.05.15 |
|---|---|
| Flutter 개발환경 설치와 첫 실행 (0) | 2026.05.08 |