tetris_engine 1.3.0 copy "tetris_engine: ^1.3.0" to clipboard
tetris_engine: ^1.3.0 copied to clipboard

A complete, customizable Tetris engine for Flutter: Guideline rules (SRS, 7-bag, hold, lock delay, T-Spins), themes, touch and keyboard controls, replays and save/restore.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tetris_engine/tetris_engine.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]);
  runApp(const TetrisExampleApp());
}

class TetrisExampleApp extends StatefulWidget {
  const TetrisExampleApp({super.key});

  @override
  State<TetrisExampleApp> createState() => _TetrisExampleAppState();
}

class _TetrisExampleAppState extends State<TetrisExampleApp> {
  TetrisTheme _theme = darkTetrisTheme;
  final _themeNotifier = ValueNotifier<TetrisTheme>(darkTetrisTheme);

  void _cycleTheme() {
    TetrisTheme next;
    if (_theme == darkTetrisTheme) {
      next = defaultTetrisTheme;
    } else if (_theme == defaultTetrisTheme) {
      next = colorblindTetrisTheme;
    } else {
      next = darkTetrisTheme;
    }
    setState(() => _theme = next);
    _themeNotifier.value = next;
  }

  @override
  Widget build(BuildContext context) {
    final isDark = _theme == darkTetrisTheme || _theme == colorblindTetrisTheme;
    return MaterialApp(
      title: 'Flutter Tetris',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: isDark
            ? const ColorScheme.dark(primary: Colors.white)
            : const ColorScheme.light(primary: Colors.black),
        useMaterial3: true,
      ),
      home: TetrisHome(
        activeTheme: _theme,
        themeNotifier: _themeNotifier,
        onCycleTheme: _cycleTheme,
      ),
    );
  }
}

class TetrisHome extends StatefulWidget {
  final TetrisTheme activeTheme;
  final ValueNotifier<TetrisTheme> themeNotifier;
  final VoidCallback onCycleTheme;

  const TetrisHome({
    super.key,
    required this.activeTheme,
    required this.themeNotifier,
    required this.onCycleTheme,
  });

  @override
  State<TetrisHome> createState() => _TetrisHomeState();
}

class _TetrisHomeState extends State<TetrisHome> {
  _Screen _screen = _Screen.menu;
  late TetrisGame _game;
  StreamSubscription<TetrisEvent>? _events;
  int _highScore = 0;

  /// Shared by every game so lifetime stats survive new games.
  final _statistics = TetrisStatistics();

  @override
  void initState() {
    super.initState();
    _game = _buildGame();
  }

  TetrisGame _buildGame() {
    final game = TetrisGame(
      statistics: _statistics,
      onScoreChanged: (s) {
        if (s > _highScore) setState(() => _highScore = s);
      },
      onGameOver: () => setState(() => _screen = _Screen.gameOver),
    );
    _events?.cancel();
    _events = game.events.listen(_onEvent);
    return game;
  }

  void _onEvent(TetrisEvent e) {
    switch (e.type) {
      case TetrisEventType.levelUp:
        _showBanner('LEVEL ${e.level}!');
      case TetrisEventType.linesCleared:
        final parts = [
          if (e.backToBack) 'BACK-TO-BACK',
          if (e.tSpin == TSpinType.full) 'T-SPIN',
          if (e.tSpin == TSpinType.mini) 'T-SPIN MINI',
          if (e.lines == 4) 'TETRIS!',
          if (e.perfectClear) 'PERFECT CLEAR!',
          if (e.combo > 1) '${e.combo - 1} COMBO',
        ];
        if (parts.isNotEmpty) _showBanner(parts.join('  '));
      case TetrisEventType.tSpin:
        _showBanner(e.tSpin == TSpinType.full ? 'T-SPIN' : 'T-SPIN MINI');
      default:
        break;
    }
  }

  void _showBanner(String message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(
        SnackBar(
          content: Text(
            message,
            style: const TextStyle(
              fontWeight: FontWeight.w800,
              letterSpacing: 2,
            ),
          ),
          duration: const Duration(milliseconds: 900),
          behavior: SnackBarBehavior.floating,
          margin: const EdgeInsets.only(bottom: 80, left: 40, right: 40),
          shape: const RoundedRectangleBorder(),
        ),
      );
  }

  void _startGame() {
    _game.dispose();
    _game = _buildGame();
    _game.start();
    setState(() => _screen = _Screen.game);
  }

  void _restartGame() {
    _game.restart();
    setState(() => _screen = _Screen.game);
  }

  void _goToMenu() {
    _game.pause();
    setState(() => _screen = _Screen.menu);
  }

  @override
  void dispose() {
    _events?.cancel();
    _game.dispose();
    super.dispose();
  }

  /// Per-level colors built on top of the selected theme.
  LevelThemes? _levelThemes;

  LevelThemes _levelThemesFor(TetrisTheme base) {
    final cached = _levelThemes;
    if (cached != null && cached.base == base) return cached;
    return _levelThemes = LevelThemes(base: base);
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<TetrisTheme>(
      valueListenable: widget.themeNotifier,
      builder: (context, theme, _) {
        if (_screen != _Screen.game) return _buildScreen(theme);
        // Colors change every level while playing.
        return LevelThemeBuilder(
          game: _game,
          levelThemes: _levelThemesFor(theme),
          builder: (context, levelTheme, _) => _buildScreen(levelTheme),
        );
      },
    );
  }

  Widget _buildScreen(TetrisTheme theme) {
    return Scaffold(
      backgroundColor: theme.boardBackground,
      body: switch (_screen) {
        _Screen.menu => _MenuScreen(
          theme: theme,
          highScore: _highScore,
          onStart: _startGame,
          onStats: () => setState(() => _screen = _Screen.stats),
          onCycleTheme: widget.onCycleTheme,
          activeTheme: widget.activeTheme,
        ),
        _Screen.game => _GameScreen(
          game: _game,
          theme: theme,
          onMenu: _goToMenu,
          onRestart: _restartGame,
        ),
        _Screen.gameOver => _GameOverScreen(
          theme: theme,
          score: _game.state.scoreState.score,
          level: _game.state.levelState.level,
          lines: _game.state.levelState.linesCleared,
          highScore: _highScore,
          onRestart: _restartGame,
          onMenu: () => setState(() => _screen = _Screen.menu),
        ),
        _Screen.stats => _StatsScreen(
          theme: theme,
          statistics: _statistics,
          onBack: () => setState(() => _screen = _Screen.menu),
        ),
      },
    );
  }
}

enum _Screen { menu, game, gameOver, stats }

// ─── Menu screen ─────────────────────────────────────────────────────────────

class _MenuScreen extends StatelessWidget {
  final TetrisTheme theme;
  final int highScore;
  final VoidCallback onStart;
  final VoidCallback onStats;
  final VoidCallback onCycleTheme;
  final TetrisTheme activeTheme;

  const _MenuScreen({
    required this.theme,
    required this.highScore,
    required this.onStart,
    required this.onStats,
    required this.onCycleTheme,
    required this.activeTheme,
  });

  String get _themeName {
    if (activeTheme == darkTetrisTheme) return 'DARK';
    if (activeTheme == defaultTetrisTheme) return 'LIGHT';
    return 'COLOR-BLIND';
  }

  @override
  Widget build(BuildContext context) {
    final fg = theme == defaultTetrisTheme ? Colors.black : Colors.white;
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 40),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const SizedBox(height: 60),
            Text(
              'TETRIS',
              style: TextStyle(
                fontSize: 52,
                fontWeight: FontWeight.w900,
                color: fg,
                letterSpacing: 8,
                height: 1,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              'tetris_engine',
              style: TextStyle(
                fontSize: 12,
                fontWeight: FontWeight.w400,
                color: fg.withAlpha(100),
                letterSpacing: 3,
              ),
            ),
            const SizedBox(height: 48),
            if (highScore > 0) ...[
              Text('BEST', style: theme.labelStyle),
              const SizedBox(height: 2),
              Text(
                highScore.toString(),
                style: theme.valueStyle.copyWith(fontSize: 32),
              ),
              const SizedBox(height: 40),
            ],
            _MenuButton(
              label: 'START',
              onTap: onStart,
              theme: theme,
              primary: true,
            ),
            const SizedBox(height: 12),
            _MenuButton(label: 'STATISTICS', onTap: onStats, theme: theme),
            const SizedBox(height: 12),
            _MenuButton(
              label: 'THEME: $_themeName',
              onTap: onCycleTheme,
              theme: theme,
            ),
            const Spacer(),
            // Mini controls legend
            _ControlsLegend(theme: theme),
            const SizedBox(height: 24),
          ],
        ),
      ),
    );
  }
}

class _MenuButton extends StatelessWidget {
  final String label;
  final VoidCallback onTap;
  final TetrisTheme theme;
  final bool primary;

  const _MenuButton({
    required this.label,
    required this.onTap,
    required this.theme,
    this.primary = false,
  });

  @override
  Widget build(BuildContext context) {
    final fg = theme == defaultTetrisTheme ? Colors.black : Colors.white;
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: double.infinity,
        padding: const EdgeInsets.symmetric(vertical: 14),
        decoration: BoxDecoration(
          color: primary ? fg : Colors.transparent,
          border: Border.all(color: fg.withAlpha(primary ? 0 : 80), width: 1),
        ),
        child: Text(
          label,
          textAlign: TextAlign.center,
          style: TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.w700,
            letterSpacing: 2.5,
            color: primary ? theme.boardBackground : fg.withAlpha(200),
          ),
        ),
      ),
    );
  }
}

class _ControlsLegend extends StatelessWidget {
  final TetrisTheme theme;

  const _ControlsLegend({required this.theme});

  @override
  Widget build(BuildContext context) {
    final fg = theme == defaultTetrisTheme
        ? Colors.black.withAlpha(100)
        : Colors.white.withAlpha(80);
    final entries = [
      ('← →', 'Move'),
      ('↑ / W', 'Rotate CW'),
      ('Z', 'Rotate CCW'),
      ('↓ / S', 'Soft drop'),
      ('Space', 'Hard drop'),
      ('C', 'Hold'),
      ('P / Esc', 'Pause'),
    ];
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'KEYBOARD',
          style: TextStyle(
            fontSize: 10,
            fontWeight: FontWeight.w700,
            letterSpacing: 2,
            color: fg,
          ),
        ),
        const SizedBox(height: 8),
        Wrap(
          spacing: 16,
          runSpacing: 4,
          children: entries
              .map(
                (e) => Text(
                  '${e.$1}  ${e.$2}',
                  style: TextStyle(fontSize: 10, color: fg),
                ),
              )
              .toList(),
        ),
      ],
    );
  }
}

// ─── Game screen ─────────────────────────────────────────────────────────────

bool _isLight(TetrisTheme theme) =>
    theme.boardBackground.computeLuminance() > 0.5;

class _GameScreen extends StatelessWidget {
  final TetrisGame game;
  final TetrisTheme theme;
  final VoidCallback onMenu;
  final VoidCallback onRestart;

  const _GameScreen({
    required this.game,
    required this.theme,
    required this.onMenu,
    required this.onRestart,
  });

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: OrientationBuilder(
        builder: (context, orientation) => orientation == Orientation.portrait
            ? _PortraitLayout(game: game, theme: theme, onMenu: onMenu)
            : _LandscapeLayout(game: game, theme: theme, onMenu: onMenu),
      ),
    );
  }
}

// Portrait: board center, panels above + below + side
class _PortraitLayout extends StatelessWidget {
  final TetrisGame game;
  final TetrisTheme theme;
  final VoidCallback onMenu;

  const _PortraitLayout({
    required this.game,
    required this.theme,
    required this.onMenu,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Top bar
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          child: Row(
            children: [
              GestureDetector(
                onTap: onMenu,
                child: Icon(
                  Icons.arrow_back_ios_new,
                  size: 18,
                  color: _isLight(theme) ? Colors.black54 : Colors.white38,
                ),
              ),
              const Spacer(),
              ScorePanel(game: game, theme: theme),
              const Spacer(),
              ListenableBuilder(
                listenable: game,
                builder: (_, _) => GestureDetector(
                  onTap: () => game.state.status == TetrisGameStatus.playing
                      ? game.pause()
                      : game.resume(),
                  child: Icon(
                    game.state.status == TetrisGameStatus.playing
                        ? Icons.pause
                        : Icons.play_arrow,
                    size: 22,
                    color: _isLight(theme) ? Colors.black54 : Colors.white38,
                  ),
                ),
              ),
            ],
          ),
        ),

        // Board + side panels
        Expanded(
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // Left panel
              Padding(
                padding: const EdgeInsets.only(right: 8, top: 4),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    HoldPiecePreview(game: game, theme: theme),
                    const SizedBox(height: 16),
                    LevelPanel(game: game, theme: theme),
                    const SizedBox(height: 8),
                    LinesClearedPanel(game: game, theme: theme),
                  ],
                ),
              ),

              // Board — constrained so it doesn't overflow
              ConstrainedBox(
                constraints: BoxConstraints(
                  maxWidth: MediaQuery.of(context).size.width * 0.5,
                ),
                child: TetrisBoard(
                  game: game,
                  theme: theme,
                  showGhostPiece: true,
                  enableKeyboard: true,
                  enableGestures: true,
                  pauseOverlayBuilder: (ctx, resume) => _CustomPauseOverlay(
                    theme: theme,
                    onResume: resume,
                    onMenu: onMenu,
                  ),
                ),
              ),

              // Right panel
              Padding(
                padding: const EdgeInsets.only(left: 8, top: 4),
                child: NextPiecePreview(game: game, theme: theme, count: 4),
              ),
            ],
          ),
        ),

        // On-screen controls from the package
        TetrisControlPad(game: game, theme: theme, buttonSize: 38),
      ],
    );
  }
}

// Landscape: hold+stats left, board center, next right
class _LandscapeLayout extends StatelessWidget {
  final TetrisGame game;
  final TetrisTheme theme;
  final VoidCallback onMenu;

  const _LandscapeLayout({
    required this.game,
    required this.theme,
    required this.onMenu,
  });

  @override
  Widget build(BuildContext context) {
    final h = MediaQuery.of(context).size.height;
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        // Left panel
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              HoldPiecePreview(game: game, theme: theme),
              const SizedBox(height: 12),
              ScorePanel(game: game, theme: theme),
              const SizedBox(height: 8),
              LevelPanel(game: game, theme: theme),
              const SizedBox(height: 8),
              LinesClearedPanel(game: game, theme: theme),
              const SizedBox(height: 12),
              GestureDetector(
                onTap: onMenu,
                child: Text('MENU', style: theme.labelStyle),
              ),
            ],
          ),
        ),

        // Board
        SizedBox(
          height: h * 0.92,
          child: AspectRatio(
            aspectRatio: 10 / 20,
            child: TetrisBoard(
              game: game,
              theme: theme,
              showGhostPiece: true,
              enableKeyboard: true,
              enableGestures: true,
              pauseOverlayBuilder: (ctx, resume) => _CustomPauseOverlay(
                theme: theme,
                onResume: resume,
                onMenu: onMenu,
              ),
            ),
          ),
        ),

        // Right panel
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: NextPiecePreview(game: game, theme: theme, count: 5),
        ),
      ],
    );
  }
}

// ─── Custom pause overlay ─────────────────────────────────────────────────────

class _CustomPauseOverlay extends StatelessWidget {
  final TetrisTheme theme;
  final VoidCallback onResume;
  final VoidCallback onMenu;

  const _CustomPauseOverlay({
    required this.theme,
    required this.onResume,
    required this.onMenu,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      color: theme.overlayBackground,
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              'PAUSED',
              style: theme.valueStyle.copyWith(
                fontSize: 26,
                color: Colors.white,
                letterSpacing: 4,
              ),
            ),
            const SizedBox(height: 28),
            _OverlayBtn(label: 'RESUME', onTap: onResume, filled: true),
            const SizedBox(height: 10),
            _OverlayBtn(label: 'MENU', onTap: onMenu),
          ],
        ),
      ),
    );
  }
}

class _OverlayBtn extends StatelessWidget {
  final String label;
  final VoidCallback onTap;
  final bool filled;

  const _OverlayBtn({
    required this.label,
    required this.onTap,
    this.filled = false,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 140,
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: filled ? Colors.white : Colors.transparent,
          border: Border.all(color: Colors.white, width: 1),
        ),
        child: Text(
          label,
          textAlign: TextAlign.center,
          style: TextStyle(
            fontSize: 12,
            fontWeight: FontWeight.w700,
            letterSpacing: 2,
            color: filled ? Colors.black : Colors.white,
          ),
        ),
      ),
    );
  }
}

// ─── Game over screen ─────────────────────────────────────────────────────────

class _GameOverScreen extends StatelessWidget {
  final TetrisTheme theme;
  final int score;
  final int level;
  final int lines;
  final int highScore;
  final VoidCallback onRestart;
  final VoidCallback onMenu;

  const _GameOverScreen({
    required this.theme,
    required this.score,
    required this.level,
    required this.lines,
    required this.highScore,
    required this.onRestart,
    required this.onMenu,
  });

  @override
  Widget build(BuildContext context) {
    final fg = theme == defaultTetrisTheme ? Colors.black : Colors.white;
    final isNewBest = score >= highScore && score > 0;

    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 40),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const SizedBox(height: 60),
            Text(
              'GAME\nOVER',
              style: TextStyle(
                fontSize: 48,
                fontWeight: FontWeight.w900,
                color: fg,
                letterSpacing: 6,
                height: 1.05,
              ),
            ),
            const SizedBox(height: 48),
            if (isNewBest) ...[
              Text(
                'NEW BEST',
                style: theme.labelStyle.copyWith(
                  color: const Color(0xFFFFD700),
                ),
              ),
              const SizedBox(height: 4),
            ],
            _StatRow(label: 'SCORE', value: score.toString(), theme: theme),
            const SizedBox(height: 12),
            _StatRow(label: 'BEST', value: highScore.toString(), theme: theme),
            const SizedBox(height: 12),
            _StatRow(label: 'LEVEL', value: level.toString(), theme: theme),
            const SizedBox(height: 12),
            _StatRow(label: 'LINES', value: lines.toString(), theme: theme),
            const SizedBox(height: 48),
            _MenuButton(
              label: 'PLAY AGAIN',
              onTap: onRestart,
              theme: theme,
              primary: true,
            ),
            const SizedBox(height: 12),
            _MenuButton(label: 'MENU', onTap: onMenu, theme: theme),
          ],
        ),
      ),
    );
  }
}

class _StatRow extends StatelessWidget {
  final String label;
  final String value;
  final TetrisTheme theme;

  const _StatRow({
    required this.label,
    required this.value,
    required this.theme,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(label, style: theme.labelStyle),
        Text(value, style: theme.valueStyle.copyWith(fontSize: 20)),
      ],
    );
  }
}

// ─── Statistics screen ────────────────────────────────────────────────────────

class _StatsScreen extends StatelessWidget {
  final TetrisTheme theme;
  final TetrisStatistics statistics;
  final VoidCallback onBack;

  const _StatsScreen({
    required this.theme,
    required this.statistics,
    required this.onBack,
  });

  @override
  Widget build(BuildContext context) {
    final fg = theme == defaultTetrisTheme ? Colors.black : Colors.white;
    return SafeArea(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
            child: Row(
              children: [
                GestureDetector(
                  onTap: onBack,
                  child: Icon(
                    Icons.arrow_back_ios_new,
                    size: 18,
                    color: fg.withAlpha(120),
                  ),
                ),
                const SizedBox(width: 16),
                Text(
                  'STATISTICS',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.w800,
                    letterSpacing: 3,
                    color: fg,
                  ),
                ),
              ],
            ),
          ),
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.all(16),
              child: StatisticsPanel(statistics: statistics, theme: theme),
            ),
          ),
        ],
      ),
    );
  }
}
2
likes
160
points
175
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A complete, customizable Tetris engine for Flutter: Guideline rules (SRS, 7-bag, hold, lock delay, T-Spins), themes, touch and keyboard controls, replays and save/restore.

Repository (GitHub)
View/report issues
Contributing

Topics

#tetris #game #puzzle #game-engine #widget

License

MIT (license)

Dependencies

flutter

More

Packages that depend on tetris_engine