tetris_engine 1.3.0
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.
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),
),
),
],
),
);
}
}