본문 바로가기
Flutter

Flutter 테트리스 소스 분석

by ERLite 2026. 5. 27.

🎮 먼저 테트리스 전체 소스입니다.

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점
  • 레벨은 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의 AnimationControllerCustomPainter를 활용해 다양한 효과를 구현합니다.

  1. 신기록 배너 애니메이션
    • _bannerCtrl + CurvedAnimation(Curves.elasticOut)
    • "NEW RECORD!" 배너가 튀어나오는 듯한 효과.
  2. 점수 플래시 애니메이션
    • _flashCtrl + Curves.easeOut
    • 점수 갱신 시 화면 전체가 노란빛으로 반짝임.
  3. 파티클 효과
    • _Particle 객체에 위치, 속도, 크기, 색상, 투명도 정의.
    • _ParticlePainter에서 원형 파티클을 그려 축하 불꽃처럼 흩날리게 함.
    • 중력(vy += 0.0003)과 투명도 감소(opacity -= 0.012)로 자연스러운 소멸 연출.

📌 요약

  • 점수는 라인 제거 개수 × 레벨에 따라 계산.
  • 블록 회전은 좌표 변환 + 벽킥으로 처리.
  • UI는 애니메이션 컨트롤러 + 커스텀 페인터로 신기록 배너, 점수 플래시, 파티클 효과를 구현.

 

'Flutter' 카테고리의 다른 글

Flutter 사용 기본사항  (0) 2026.05.15
Flutter 개발환경 설치와 첫 실행  (0) 2026.05.08