// Optional named actions: onWin, onGameOver. Variables: game2048_score, game2048_best. import 'dart:math' show Random; import 'dart:math' as math; import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:my_app/appeditor/custom/AppEditorWidgetContext.dart'; Widget buildCustomWidget(BuildContext context, AppEditorWidgetContext app) { return LayoutBuilder( builder: (context, constraints) { final requestedHeight = double.tryParse(app.params['height']?.toString() ?? '') ?? 600; final height = constraints.hasBoundedHeight ? constraints.maxHeight : requestedHeight.clamp(200.0, 2000.0).toDouble(); final width = constraints.hasBoundedWidth ? constraints.maxWidth : 400.0; return SizedBox( width: width, height: height, child: Theme( data: ThemeData( useMaterial3: true, scaffoldBackgroundColor: AppColors.background, colorScheme: ColorScheme.fromSeed( seedColor: AppColors.accent, surface: AppColors.background, ), ), child: Directionality( textDirection: TextDirection.rtl, child: GamePage(app: app), ), ), ); }, ); } /// جهت‌های ممکن برای حرکت کاشی‌ها. enum MoveDirection { up, down, left, right } /// یک کاشی روی صفحه. /// /// هر کاشی علاوه بر موقعیت فعلی، موقعیت قبلی خود را هم نگه می‌دارد تا لایه‌ی /// نمایش بتواند بین این دو نقطه انیمیشن حرکت بسازد. class Tile { Tile({ required this.id, required this.value, required this.row, required this.col, this.isNew = true, }) : fromRow = row, fromCol = col, previousValue = value; /// شناسه‌ی یکتا؛ برای اینکه ویجت هر کاشی در طول انیمیشن ثابت بماند. final int id; /// مقدار فعلی کاشی (۲، ۴، ۸، ...). int value; /// مقدار پیش از ادغام؛ در حین انیمیشن حرکت همین مقدار نمایش داده می‌شود. int previousValue; /// موقعیت فعلی. int row; int col; /// موقعیت پیش از آخرین حرکت (مبدأ انیمیشن). int fromRow; int fromCol; /// این کاشی همین حالا تولید شده و باید با انیمیشن ظاهر شود. bool isNew; /// این کاشی نتیجه‌ی ادغام دو کاشی است و باید «تپش» کند. bool merged = false; /// این کاشی در کاشی دیگری ادغام شده؛ فقط تا پایان انیمیشن حرکت زنده می‌ماند. bool isGhost = false; /// یک کپی پاک برای ذخیره در تاریخچه (قابلیت برگشت). Tile copy() => Tile(id: id, value: value, row: row, col: col, isNew: false); @override String toString() => 'Tile($id, $value, [$row,$col])'; } /// عکس لحظه‌ای از وضعیت بازی، برای قابلیت «برگشت». class _Snapshot { const _Snapshot(this.tiles, this.score, this.won); final List tiles; final int score; final bool won; } /// موتور بازی: تمام قواعد ۲۰۴۸ اینجاست و هیچ وابستگی‌ای به Flutter ندارد. class Board { Board({this.size = 4, this.winningValue = 2048}) { restart(); } /// طول ضلع صفحه (پیش‌فرض ۴ در ۴). final int size; /// مقداری که رسیدن به آن یعنی برد. final int winningValue; final Random _random = Random(); final List tiles = []; final List<_Snapshot> _history = []; int score = 0; int best = 0; /// امتیازی که در آخرین حرکت به دست آمد (برای نمایش «+۴»). int lastGain = 0; bool won = false; bool keepPlaying = false; bool gameOver = false; int _idCounter = 0; bool get canUndo => _history.isNotEmpty; /// آیا بازی منتظر تصمیم کاربر است (برد یا باخت) و ورودی را نادیده می‌گیرد؟ bool get isBlocked => gameOver || (won && !keepPlaying); /// شروع دوباره؛ رکورد حفظ می‌شود. void restart() { tiles.clear(); _history.clear(); score = 0; lastGain = 0; won = false; keepPlaying = false; gameOver = false; _addRandomTile(); _addRandomTile(); } /// یک حرکت انجام می‌دهد و می‌گوید آیا صفحه تغییر کرد یا نه. bool move(MoveDirection direction) { if (isBlocked) return false; _prepareForMove(); final before = _Snapshot(tiles.map((t) => t.copy()).toList(), score, won); final grid = _buildGrid(); final vector = _vectorOf(direction); var rows = List.generate(size, (i) => i); var cols = List.generate(size, (i) => i); // همیشه از سمتی که کاشی‌ها به آن حرکت می‌کنند شروع می‌کنیم. if (direction == MoveDirection.down) rows = rows.reversed.toList(); if (direction == MoveDirection.right) cols = cols.reversed.toList(); var moved = false; var gained = 0; for (final r in rows) { for (final c in cols) { final tile = grid[r][c]; if (tile == null) continue; // دورترین خانه‌ی خالی در مسیر حرکت را پیدا کن. var newRow = r; var newCol = c; while (true) { final nextRow = newRow + vector.$1; final nextCol = newCol + vector.$2; if (!_inBounds(nextRow, nextCol) || grid[nextRow][nextCol] != null) { break; } newRow = nextRow; newCol = nextCol; } final aheadRow = newRow + vector.$1; final aheadCol = newCol + vector.$2; final ahead = _inBounds(aheadRow, aheadCol) ? grid[aheadRow][aheadCol] : null; if (ahead != null && ahead.value == tile.value && !ahead.merged) { // ادغام: کاشی فعلی به خانه‌ی مقصد می‌رود و بعد از انیمیشن حذف می‌شود. grid[r][c] = null; tile.row = aheadRow; tile.col = aheadCol; tile.isGhost = true; ahead.value *= 2; ahead.merged = true; gained += ahead.value; if (ahead.value >= winningValue) won = true; moved = true; } else if (newRow != r || newCol != c) { grid[r][c] = null; grid[newRow][newCol] = tile; tile.row = newRow; tile.col = newCol; moved = true; } } } if (!moved) return false; score += gained; lastGain = gained; if (score > best) best = score; _history.add(before); if (_history.length > 30) _history.removeAt(0); _addRandomTile(); gameOver = !_hasMovesLeft(); return true; } /// برگشت به وضعیت پیش از آخرین حرکت. void undo() { if (_history.isEmpty) return; final snapshot = _history.removeLast(); tiles ..clear() ..addAll(snapshot.tiles); score = snapshot.score; won = snapshot.won; lastGain = 0; gameOver = false; } /// بعد از برد، ادامه‌ی بازی برای رسیدن به کاشی‌های بزرگ‌تر. void continueAfterWin() => keepPlaying = true; // ---------------------------------------------------------------- کمکی‌ها /// پاک کردن آثار حرکت قبلی: حذف کاشی‌های ادغام‌شده و ثبت مبدأ انیمیشن. void _prepareForMove() { tiles.removeWhere((t) => t.isGhost); for (final tile in tiles) { tile.fromRow = tile.row; tile.fromCol = tile.col; tile.previousValue = tile.value; tile.isNew = false; tile.merged = false; } } List> _buildGrid() { final grid = List.generate(size, (_) => List.filled(size, null)); for (final tile in tiles) { if (tile.isGhost) continue; grid[tile.row][tile.col] = tile; } return grid; } bool _inBounds(int row, int col) => row >= 0 && row < size && col >= 0 && col < size; /// بردار جابه‌جایی هر جهت به صورت (سطر، ستون). (int, int) _vectorOf(MoveDirection direction) => switch (direction) { MoveDirection.up => (-1, 0), MoveDirection.down => (1, 0), MoveDirection.left => (0, -1), MoveDirection.right => (0, 1), }; void _addRandomTile() { final grid = _buildGrid(); final empty = <(int, int)>[]; for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { if (grid[r][c] == null) empty.add((r, c)); } } if (empty.isEmpty) return; final spot = empty[_random.nextInt(empty.length)]; tiles.add( Tile( id: _idCounter++, // ۹۰٪ کاشی ۲ و ۱۰٪ کاشی ۴ — همان نسبت بازی اصلی. value: _random.nextDouble() < 0.9 ? 2 : 4, row: spot.$1, col: spot.$2, ), ); } /// آیا هنوز حرکتی ممکن است؟ (خانه‌ی خالی یا دو همسایه‌ی هم‌مقدار) bool _hasMovesLeft() { final grid = _buildGrid(); for (var r = 0; r < size; r++) { for (var c = 0; c < size; c++) { final tile = grid[r][c]; if (tile == null) return true; if (c + 1 < size && grid[r][c + 1]?.value == tile.value) return true; if (r + 1 < size && grid[r + 1][c]?.value == tile.value) return true; } } return false; } } /// پالت رنگ کلاسیک ۲۰۴۸. class AppColors { const AppColors._(); static const background = Color(0xFFFAF8EF); static const boardBackground = Color(0xFFBBADA0); static const emptyCell = Color(0xFFCDC1B4); static const darkText = Color(0xFF776E65); static const lightText = Color(0xFFF9F6F2); static const accent = Color(0xFF8F7A66); static const overlayVeil = Color(0xCCEEE4DA); static const _tileColors = { 2: Color(0xFFEEE4DA), 4: Color(0xFFEDE0C8), 8: Color(0xFFF2B179), 16: Color(0xFFF59563), 32: Color(0xFFF67C5F), 64: Color(0xFFF65E3B), 128: Color(0xFFEDCF72), 256: Color(0xFFEDCC61), 512: Color(0xFFEDC850), 1024: Color(0xFFEDC53F), 2048: Color(0xFFEDC22E), }; static Color tileColor(int value) => _tileColors[value] ?? const Color(0xFF3C3A32); static Color tileTextColor(int value) => value <= 4 ? darkText : lightText; /// از ۱۲۸ به بالا یک درخشش ملایم پشت کاشی می‌نشیند. static List tileGlow(int value, double cellSize) { if (value < 128) return const []; final strength = (value / 2048).clamp(0.15, 1.0); return [ BoxShadow( color: const Color(0xFFF2B179).withValues(alpha: 0.35 * strength), blurRadius: cellSize * 0.35, spreadRadius: cellSize * 0.02, ), ]; } } /// صفحه‌ی بازی: پس‌زمینه‌ی خانه‌های خالی + کاشی‌های متحرک روی آن. /// /// یک کنترلر انیمیشن مشترک برای همه‌ی کاشی‌ها استفاده می‌شود: /// بازه‌ی ۰ تا ۰٫۶ حرکت (لغزش) و بازه‌ی ۰٫۵۵ تا ۱ ظاهر شدن و تپش. class BoardView extends StatelessWidget { const BoardView({super.key, required this.board, required this.animation}); final Board board; final Animation animation; static const double _gapRatio = 0.035; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final boardSize = constraints.biggest.shortestSide; final gap = boardSize * _gapRatio; final cell = (boardSize - gap * (board.size + 1)) / board.size; return SizedBox( width: boardSize, height: boardSize, child: Stack( children: [ // پس‌زمینه‌ی ثابت Container( decoration: BoxDecoration( color: AppColors.boardBackground, borderRadius: BorderRadius.circular(boardSize * 0.03), ), ), for (var r = 0; r < board.size; r++) for (var c = 0; c < board.size; c++) Positioned( top: gap + r * (cell + gap), left: gap + c * (cell + gap), width: cell, height: cell, child: DecoratedBox( decoration: BoxDecoration( color: AppColors.emptyCell, borderRadius: BorderRadius.circular(cell * 0.08), ), ), ), // کاشی‌های متحرک AnimatedBuilder( animation: animation, builder: (context, _) => Stack(children: _buildTiles(gap, cell)), ), ], ), ); }, ); } List _buildTiles(double gap, double cell) { final t = animation.value; final slide = Curves.easeOut.transform(_progress(t, 0.0, 0.6)); final pop = _progress(t, 0.55, 1.0); // ترتیب رسم: ابتدا کاشی‌های در حال ادغام‌شدن (زیر)، بعد بقیه، و در نهایت // کاشی‌های تازه و ادغام‌شده که باید روی همه دیده شوند. final ordered = [...board.tiles]..sort((a, b) => _layer(a) - _layer(b)); return [ for (final tile in ordered) _positionedTile(tile, gap, cell, slide, pop), ]; } int _layer(Tile tile) { if (tile.isGhost) return 0; if (tile.merged || tile.isNew) return 2; return 1; } Widget _positionedTile( Tile tile, double gap, double cell, double slide, double pop, ) { final row = lerpDouble(tile.fromRow, tile.row, slide)!; final col = lerpDouble(tile.fromCol, tile.col, slide)!; double scale = 1; if (tile.isNew) { // ظاهر شدن با یک پرش کوچک scale = Curves.easeOutBack.transform(pop); } else if (tile.merged) { // تپش کوتاه در لحظه‌ی ادغام scale = 1 + 0.16 * math.sin(math.pi * pop); } // تا وقتی حرکت تمام نشده، مقدار قبلی نمایش داده می‌شود تا عدد جدید // ناگهانی وسط راه ظاهر نشود. final value = tile.merged && slide < 1.0 ? tile.previousValue : tile.value; return Positioned( key: ValueKey(tile.id), top: gap + row * (cell + gap), left: gap + col * (cell + gap), width: cell, height: cell, child: Transform.scale( scale: scale.clamp(0.0, 2.0), child: TileView(value: value, cellSize: cell), ), ); } /// نگاشت یک بازه‌ی زمانی از کنترلر به بازه‌ی ۰ تا ۱. static double _progress(double value, double begin, double end) => ((value - begin) / (end - begin)).clamp(0.0, 1.0); } /// ظاهر یک کاشی: رنگ، گردی گوشه و عددی که با بزرگ‌شدن مقدار کوچک‌تر می‌شود. class TileView extends StatelessWidget { const TileView({super.key, required this.value, required this.cellSize}); final int value; final double cellSize; double get _fontSize { final base = switch (value) { < 100 => 0.46, < 1000 => 0.38, < 10000 => 0.30, _ => 0.24, }; return cellSize * base; } @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: AppColors.tileColor(value), borderRadius: BorderRadius.circular(cellSize * 0.08), boxShadow: AppColors.tileGlow(value, cellSize), ), child: Center( child: FittedBox( fit: BoxFit.scaleDown, child: Padding( padding: EdgeInsets.symmetric(horizontal: cellSize * 0.08), child: Text( '$value', textDirection: TextDirection.ltr, style: TextStyle( fontSize: _fontSize, fontWeight: FontWeight.w800, color: AppColors.tileTextColor(value), height: 1, ), ), ), ), ), ); } } class GamePage extends StatefulWidget { const GamePage({super.key, required this.app}); final AppEditorWidgetContext app; @override State createState() => _GamePageState(); } class _GamePageState extends State with TickerProviderStateMixin { final Board _board = Board(); final FocusNode _focusNode = FocusNode(); late final AnimationController _moveController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), ); late final AnimationController _scoreController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); Future _publishQueue = Future.value(); bool _reportedWin = false; bool _reportedGameOver = false; void _publishGameState() { final app = widget.app; final score = _board.score; final best = _board.best; final reportWin = _board.won && !_reportedWin; final reportGameOver = _board.gameOver && !_reportedGameOver; _reportedWin = _board.won; _reportedGameOver = _board.gameOver; _publishQueue = _publishQueue .then((_) async { if (!mounted || !app.mounted) return; await app.addVar( name: 'game2048_score', value: score, overwrite: true, ); await app.addVar( name: 'game2048_best', value: best, storeOnDevice: true, overwrite: true, ); if (reportWin && app.hasAction('onWin')) await app.runAction('onWin'); if (reportGameOver && app.hasAction('onGameOver')) await app.runAction('onGameOver'); }) .catchError((Object error, StackTrace stack) { app.showMessage('خطا در ارتباط بازی با اپ: $error'); }); } Offset _dragStart = Offset.zero; bool _dragHandled = false; @override void initState() { super.initState(); _board.best = math.max( 0, int.tryParse(widget.app.getVar('game2048_best')?.toString() ?? '') ?? 0, ); _moveController.forward(from: 0); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _publishGameState(); }); } @override void dispose() { _moveController.dispose(); _scoreController.dispose(); _focusNode.dispose(); super.dispose(); } // ------------------------------------------------------------- کنترل بازی void _move(MoveDirection direction) { if (_board.isBlocked) return; if (!_board.move(direction)) return; setState(() {}); _moveController.forward(from: 0); if (_board.lastGain > 0) _scoreController.forward(from: 0); HapticFeedback.selectionClick(); _publishGameState(); } void _restart() { setState(_board.restart); _moveController.forward(from: 0); _publishGameState(); } void _undo() { if (!_board.canUndo) return; setState(_board.undo); _moveController.value = 1; _publishGameState(); } /// نگاشت کلیدهای جهت‌دار و WASD به جهت حرکت. static final Map _keyMap = { LogicalKeyboardKey.arrowUp: MoveDirection.up, LogicalKeyboardKey.keyW: MoveDirection.up, LogicalKeyboardKey.arrowDown: MoveDirection.down, LogicalKeyboardKey.keyS: MoveDirection.down, LogicalKeyboardKey.arrowLeft: MoveDirection.left, LogicalKeyboardKey.keyA: MoveDirection.left, LogicalKeyboardKey.arrowRight: MoveDirection.right, LogicalKeyboardKey.keyD: MoveDirection.right, }; void _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return; final direction = _keyMap[event.logicalKey]; if (direction != null) _move(direction); } void _onPanDown(DragDownDetails details) { _dragStart = details.localPosition; _dragHandled = false; } void _onPanUpdate(DragUpdateDetails details) { if (_dragHandled) return; final delta = details.localPosition - _dragStart; if (delta.distance < 24) return; _dragHandled = true; final direction = delta.dx.abs() > delta.dy.abs() ? (delta.dx > 0 ? MoveDirection.right : MoveDirection.left) : (delta.dy > 0 ? MoveDirection.down : MoveDirection.up); _move(direction); } // ------------------------------------------------------------------- نما @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: SafeArea( child: KeyboardListener( focusNode: _focusNode, autofocus: true, onKeyEvent: _handleKey, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _header(), const SizedBox(height: 16), _actions(), const SizedBox(height: 16), GestureDetector( behavior: HitTestBehavior.opaque, onPanDown: _onPanDown, onPanUpdate: _onPanUpdate, child: AspectRatio( aspectRatio: 1, child: Stack( children: [ BoardView( board: _board, animation: _moveController, ), if (_board.isBlocked) _resultOverlay(), ], ), ), ), const SizedBox(height: 18), Text( 'با کشیدن انگشت یا کلیدهای جهت‌دار بازی کن. ' 'کاشی‌های هم‌عدد با هم ترکیب می‌شوند.', style: TextStyle( color: AppColors.darkText.withValues(alpha: 0.75), fontSize: 13, height: 1.7, ), ), ], ), ), ), ), ), ), ); } Widget _header() { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '2048', textDirection: TextDirection.ltr, style: TextStyle( fontSize: 52, fontWeight: FontWeight.w900, color: AppColors.darkText, height: 1, ), ), SizedBox(height: 6), Text( 'تا کاشی ۲۰۴۸ برو', style: TextStyle(color: AppColors.darkText, fontSize: 13), ), ], ), const Spacer(), _ScoreBox( label: 'امتیاز', value: _board.score, gain: _board.lastGain, gainAnimation: _scoreController, ), const SizedBox(width: 8), _ScoreBox(label: 'رکورد', value: _board.best), ], ); } Widget _actions() { return Row( children: [ Expanded( child: FilledButton.icon( onPressed: _restart, icon: const Icon(Icons.refresh, size: 18), label: const Text('بازی جدید'), style: FilledButton.styleFrom( backgroundColor: AppColors.accent, foregroundColor: AppColors.lightText, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: _board.canUndo ? _undo : null, icon: const Icon(Icons.undo, size: 18), label: const Text('برگشت'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(vertical: 14), side: const BorderSide(color: AppColors.accent), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ), ], ); } Widget _resultOverlay() { final isWin = _board.won && !_board.keepPlaying; return Positioned.fill( child: TweenAnimationBuilder( key: ValueKey(isWin), tween: Tween(begin: 0, end: 1), duration: const Duration(milliseconds: 350), builder: (context, t, child) => Opacity(opacity: t, child: child), child: DecoratedBox( decoration: BoxDecoration( color: AppColors.overlayVeil, borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( isWin ? 'بردی!' : 'حرکتی باقی نمانده', style: const TextStyle( fontSize: 34, fontWeight: FontWeight.w900, color: AppColors.darkText, ), ), const SizedBox(height: 6), Text( 'امتیاز: ${_board.score}', style: const TextStyle(fontSize: 16, color: AppColors.darkText), ), const SizedBox(height: 20), Row( mainAxisSize: MainAxisSize.min, children: [ if (isWin) ...[ FilledButton( onPressed: () => setState(_board.continueAfterWin), style: FilledButton.styleFrom( backgroundColor: AppColors.accent, foregroundColor: AppColors.lightText, ), child: const Text('ادامه می‌دهم'), ), const SizedBox(width: 10), ], OutlinedButton( onPressed: _restart, style: OutlinedButton.styleFrom( foregroundColor: AppColors.accent, side: const BorderSide(color: AppColors.accent), ), child: const Text('از نو'), ), ], ), ], ), ), ), ); } } /// جعبه‌ی امتیاز؛ هنگام گرفتن امتیاز، مقدار به‌دست‌آمده به سمت بالا محو می‌شود. class _ScoreBox extends StatelessWidget { const _ScoreBox({ required this.label, required this.value, this.gain = 0, this.gainAnimation, }); final String label; final int value; final int gain; final Animation? gainAnimation; @override Widget build(BuildContext context) { return Stack( clipBehavior: Clip.none, alignment: Alignment.topCenter, children: [ Container( constraints: const BoxConstraints(minWidth: 82), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: AppColors.boardBackground, borderRadius: BorderRadius.circular(6), ), child: Column( children: [ Text( label, style: TextStyle( color: AppColors.lightText.withValues(alpha: 0.75), fontSize: 11, letterSpacing: 0.5, ), ), const SizedBox(height: 2), Text( '$value', textDirection: TextDirection.ltr, style: const TextStyle( color: AppColors.lightText, fontSize: 22, fontWeight: FontWeight.w800, height: 1.1, ), ), ], ), ), if (gainAnimation != null && gain > 0) Positioned( top: -4, child: AnimatedBuilder( animation: gainAnimation!, builder: (context, child) { final t = gainAnimation!.value; if (t == 0 || t == 1) return const SizedBox.shrink(); return Transform.translate( offset: Offset(0, -30 * t), child: Opacity(opacity: 1 - t, child: child), ); }, child: Text( '+$gain', textDirection: TextDirection.ltr, style: const TextStyle( color: AppColors.accent, fontSize: 20, fontWeight: FontWeight.w900, ), ), ), ), ], ); } }