import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'dart:async'; import 'dart:math'; import 'models/game_items.dart'; import 'game/game_manager.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Mnemo Snake', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), useMaterial3: true, ), home: const GameScreen(), ); } } class GameScreen extends StatefulWidget { const GameScreen({super.key}); @override State createState() => _GameScreenState(); } class _GameScreenState extends State with SingleTickerProviderStateMixin { static const int squaresPerRow = 12; static const int squaresPerCol = 16; late double cellSize; final List positions = []; Direction direction = Direction.right; Timer? gameTimer; late AnimationController _animationController; late GameManager gameManager; final FocusNode _focusNode = FocusNode(); Direction? _queuedDirection; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); // Initialize with default category and sample categories gameManager = GameManager( categories: [ Category( name: 'Default', correctAnswers: ['RED', 'BLUE', 'GREEN', 'YELLOW'], wrongAnswers: ['REED', 'BLUU', 'GREN', 'YELOW'], ), Category( name: 'Colors', correctAnswers: ['RED', 'BLUE', 'GREEN', 'YELLOW'], wrongAnswers: ['REED', 'BLUU', 'GREN', 'YELOW'], ), Category( name: 'Animals', correctAnswers: ['CAT', 'DOG', 'BIRD', 'FISH'], wrongAnswers: ['KAT', 'DOOG', 'BERD', 'FESH'], ), ], onGameOver: () => _handleGameOver(), ); startGame(); } void startGame() { positions.clear(); positions.add(const Offset(5, 5)); direction = Direction.right; gameManager.resetGame(); gameTimer?.cancel(); gameTimer = Timer.periodic( const Duration(milliseconds: 300), (Timer t) => _update(), ); } void _updateGameSpeed() { double currentSpeed = gameManager.getCurrentSpeed(); int updateInterval = (300 / currentSpeed).round(); _animationController.duration = Duration(milliseconds: updateInterval); gameTimer?.cancel(); gameTimer = Timer.periodic( Duration(milliseconds: updateInterval), (Timer t) => _update(), ); } void _update() { setState(() { _animationController.forward(from: 0.0); // Check if we can apply queued direction change if (_queuedDirection != null) { Offset head = positions.first; bool isNearGridX = (head.dx - head.dx.round()).abs() < 0.1; bool isNearGridY = (head.dy - head.dy.round()).abs() < 0.1; bool canTurnNow = false; if ((direction == Direction.left || direction == Direction.right) && (_queuedDirection == Direction.up || _queuedDirection == Direction.down)) { canTurnNow = isNearGridX; } else if ((direction == Direction.up || direction == Direction.down) && (_queuedDirection == Direction.left || _queuedDirection == Direction.right)) { canTurnNow = isNearGridY; } if (canTurnNow) { _changeDirection(_queuedDirection!); } } // Update snake positions in game manager gameManager.updateSnakePositions(positions, direction); // Move snake Offset newPosition = _getNextPosition(); // Check collision with game items GameItem? collectedItem = gameManager.gameItems .where((item) => item.position == newPosition) .firstOrNull; // Check self-collision if (_checkSelfCollision(newPosition)) { _handleGameOver(); return; } if (collectedItem != null) { bool success = gameManager.collectItem(collectedItem); if (!success) { _handleGameOver(); return; } // Grow snake when collecting correct items positions.insert(0, newPosition); } else { // Normal movement positions.insert(0, newPosition); positions.removeLast(); } // Spawn new items gameManager.spawnGameItem(); // Update game speed gameManager.updateBaseSpeed(); _updateGameSpeed(); }); } void _handleGameOver() { gameTimer?.cancel(); setState(() { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('Game Over'), content: Text('Score: ${gameManager.score}'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); startGame(); }, child: const Text('Play Again'), ), ], ), ); }); } Offset _getNextPosition() { Offset head = positions.first; switch (direction) { case Direction.up: return Offset(head.dx, (head.dy - 1) % squaresPerCol); case Direction.right: return Offset((head.dx + 1) % squaresPerRow, head.dy); case Direction.down: return Offset(head.dx, (head.dy + 1) % squaresPerCol); case Direction.left: return Offset((head.dx - 1) % squaresPerRow, head.dy); } } bool _checkSelfCollision(Offset newPosition) { return positions.contains(newPosition) && positions.length > 1; } void _changeDirection(Direction newDirection) { // Don't allow 180-degree turns if ((direction == Direction.up && newDirection == Direction.down) || (direction == Direction.down && newDirection == Direction.up) || (direction == Direction.left && newDirection == Direction.right) || (direction == Direction.right && newDirection == Direction.left)) { return; } // Get the head position Offset head = positions.first; // Find nearest grid position based on current direction double roundedX = head.dx; double roundedY = head.dy; if (direction == Direction.left || direction == Direction.right) { // When moving horizontally, find nearest column roundedX = (head.dx + 0.5).floor().toDouble(); } else { // When moving vertically, find nearest row roundedY = (head.dy + 0.5).floor().toDouble(); } bool canTurnNow = false; // Only allow turns at grid positions if ((direction == Direction.left || direction == Direction.right) && (newDirection == Direction.up || newDirection == Direction.down)) { canTurnNow = (head.dx - roundedX).abs() < 0.1; } else if ((direction == Direction.up || direction == Direction.down) && (newDirection == Direction.left || newDirection == Direction.right)) { canTurnNow = (head.dy - roundedY).abs() < 0.1; } if (canTurnNow) { // If we can turn now, do it and clear any queued direction positions[0] = Offset(roundedX, roundedY); direction = newDirection; _queuedDirection = null; } else { // If we can't turn now, queue the direction change _queuedDirection = newDirection; } } @override Widget build(BuildContext context) { // Calculate cell size based on screen dimensions final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.top - kToolbarHeight - 100; // Account for AppBar and score panel cellSize = min( screenWidth / squaresPerRow, screenHeight / squaresPerCol, ); return Scaffold( appBar: AppBar( title: const Text('Mnemo Snake'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), body: KeyboardListener( focusNode: _focusNode, onKeyEvent: (event) { if (event is KeyDownEvent) { switch (event.logicalKey) { case LogicalKeyboardKey.arrowUp: _changeDirection(Direction.up); break; case LogicalKeyboardKey.arrowDown: _changeDirection(Direction.down); break; case LogicalKeyboardKey.arrowLeft: _changeDirection(Direction.left); break; case LogicalKeyboardKey.arrowRight: _changeDirection(Direction.right); break; } } }, child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text('Score: ${gameManager.score}', style: const TextStyle(fontSize: 20)), const SizedBox(width: 20), Row( children: List.generate( 5, (index) => Icon( Icons.favorite, color: index < gameManager.healthPoints ? Colors.red : Colors.grey, size: 24, ), ), ), ], ), if (gameManager.targetWord != null) Row( children: [ const Text('Target: ', style: TextStyle(fontSize: 20)), ...gameManager.targetWord!.split('').map((letter) { final collected = gameManager.collectedLetters.contains(letter); return Container( margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: collected ? Colors.green : Colors.grey, borderRadius: BorderRadius.circular(4), ), child: Text( letter, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ); }).toList(), ], ) else if (gameManager.currentCategory != null) Text('Category: ${gameManager.currentCategory!.name}', style: const TextStyle(fontSize: 20)), Text( 'Speed: ${gameManager.getCurrentSpeed().toStringAsFixed(1)}x', style: const TextStyle(fontSize: 20)), ], ), ), Expanded( child: Center( child: Focus( autofocus: true, child: GestureDetector( onVerticalDragUpdate: (details) { if (details.delta.dy > 0) { _changeDirection(Direction.down); } else if (details.delta.dy < 0) { _changeDirection(Direction.up); } }, onHorizontalDragUpdate: (details) { if (details.delta.dx > 0) { _changeDirection(Direction.right); } else if (details.delta.dx < 0) { _changeDirection(Direction.left); } }, child: Container( width: cellSize * squaresPerRow, height: cellSize * squaresPerCol, color: Colors.grey[200], child: AnimatedBuilder( animation: _animationController, builder: (context, child) { return CustomPaint( painter: GamePainter( snakePositions: positions, gameItems: gameManager.gameItems, cellSize: cellSize, animationValue: _animationController.value, direction: direction, activePowerUps: gameManager.activePowerUps, ), ); }, ), ), ), ), ), ), ], ), ), ); } @override void dispose() { gameTimer?.cancel(); _animationController.dispose(); _focusNode.dispose(); super.dispose(); } } enum Direction { up, right, down, left } class GamePainter extends CustomPainter { final List snakePositions; final List gameItems; final double cellSize; final double animationValue; final Direction direction; final List activePowerUps; static const int squaresPerRow = 12; static const int squaresPerCol = 16; GamePainter({ required this.snakePositions, required this.gameItems, required this.cellSize, required this.animationValue, required this.direction, required this.activePowerUps, }); @override void paint(Canvas canvas, Size size) { // Draw grid _drawGrid(canvas, size); // Check if shield is active bool isShieldActive = activePowerUps .where((p) => p.type == PowerUpType.shield && p.isActive) .isNotEmpty; final Paint snakePaint = Paint() ..color = isShieldActive ? Colors.blue : Colors.green; final Paint eyePaint = Paint()..color = Colors.white; final Paint pupilPaint = Paint()..color = Colors.black; // Draw snake with interpolated positions if (snakePositions.isNotEmpty) { Offset head = snakePositions.first; Offset nextPos = _getNextPosition(head, direction); Offset interpolatedHead = _smoothLerp(head, nextPos, animationValue); if (snakePositions.length > 1) { // Draw body segments for (int i = snakePositions.length - 1; i > 0; i--) { Offset current = snakePositions[i]; Offset next = snakePositions[i - 1]; // Interpolate between current and next position Offset interpolated = _smoothLerp(current, next, animationValue); // Draw tail for the last segment if (i == snakePositions.length - 1) { _drawTail(canvas, interpolated, current, snakePaint); } else { // Draw regular body segment canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( interpolated.dx * cellSize, interpolated.dy * cellSize, cellSize - 4, cellSize - 4, ), const Radius.circular(8), ), snakePaint, ); } } } // Draw head canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( interpolatedHead.dx * cellSize, interpolatedHead.dy * cellSize, cellSize - 4, cellSize - 4, ), const Radius.circular(8), ), snakePaint, ); // Draw shield effect if active if (isShieldActive) { final shieldPaint = Paint() ..color = Colors.blue.withOpacity(0.3) ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTWH( interpolatedHead.dx * cellSize - 2, interpolatedHead.dy * cellSize - 2, cellSize, cellSize, ), const Radius.circular(10), ), shieldPaint, ); } // Draw eyes _drawSnakeEyes(canvas, interpolatedHead, direction, eyePaint, pupilPaint); } // Draw game items for (var item in gameItems) { _drawGameItem(canvas, item); } } void _drawGrid(Canvas canvas, Size size) { final Paint gridPaint = Paint() ..color = Colors.grey[400]! ..style = PaintingStyle.stroke ..strokeWidth = 1.0; // Draw vertical lines for (int i = 0; i <= squaresPerRow; i++) { canvas.drawLine( Offset(i * cellSize, 0), Offset(i * cellSize, squaresPerCol * cellSize), gridPaint, ); } // Draw horizontal lines for (int i = 0; i <= squaresPerCol; i++) { canvas.drawLine( Offset(0, i * cellSize), Offset(squaresPerRow * cellSize, i * cellSize), gridPaint, ); } } void _drawSnakeEyes(Canvas canvas, Offset head, Direction direction, Paint eyePaint, Paint pupilPaint) { double eyeRadius = cellSize * 0.15; double pupilRadius = eyeRadius * 0.5; double eyeOffset = cellSize * 0.2; // Calculate eye positions based on direction List eyePositions = []; List pupilOffsets = []; switch (direction) { case Direction.right: eyePositions = [ Offset(head.dx * cellSize + cellSize * 0.7, head.dy * cellSize + cellSize * 0.3), Offset(head.dx * cellSize + cellSize * 0.7, head.dy * cellSize + cellSize * 0.7), ]; pupilOffsets = [Offset(pupilRadius, 0), Offset(pupilRadius, 0)]; break; case Direction.left: eyePositions = [ Offset(head.dx * cellSize + cellSize * 0.3, head.dy * cellSize + cellSize * 0.3), Offset(head.dx * cellSize + cellSize * 0.3, head.dy * cellSize + cellSize * 0.7), ]; pupilOffsets = [Offset(-pupilRadius, 0), Offset(-pupilRadius, 0)]; break; case Direction.up: eyePositions = [ Offset(head.dx * cellSize + cellSize * 0.3, head.dy * cellSize + cellSize * 0.3), Offset(head.dx * cellSize + cellSize * 0.7, head.dy * cellSize + cellSize * 0.3), ]; pupilOffsets = [Offset(0, -pupilRadius), Offset(0, -pupilRadius)]; break; case Direction.down: eyePositions = [ Offset(head.dx * cellSize + cellSize * 0.3, head.dy * cellSize + cellSize * 0.7), Offset(head.dx * cellSize + cellSize * 0.7, head.dy * cellSize + cellSize * 0.7), ]; pupilOffsets = [Offset(0, pupilRadius), Offset(0, pupilRadius)]; break; } // Draw eyes for (int i = 0; i < 2; i++) { // Draw white part canvas.drawCircle(eyePositions[i], eyeRadius, eyePaint); // Draw pupil canvas.drawCircle( eyePositions[i].translate(pupilOffsets[i].dx, pupilOffsets[i].dy), pupilRadius, pupilPaint, ); } } void _drawTail( Canvas canvas, Offset interpolated, Offset previous, Paint snakePaint) { // Calculate tail direction based on the movement from previous to current position double dx = previous.dx - interpolated.dx; double dy = previous.dy - interpolated.dy; // Handle screen wrapping if (dx > squaresPerRow / 2) dx -= squaresPerRow; if (dx < -squaresPerRow / 2) dx += squaresPerRow; if (dy > squaresPerCol / 2) dy -= squaresPerCol; if (dy < -squaresPerCol / 2) dy += squaresPerCol; // Normalize direction double length = sqrt(dx * dx + dy * dy); if (length > 0) { dx /= length; dy /= length; } // Calculate tail points with shorter length and moved closer to body double tailLength = cellSize * 0.45; // Reduced from 0.5 double tailWidth = cellSize * 0.45; // Slightly reduced width // Calculate tail center point (moved closer to the body) double centerX = interpolated.dx * cellSize + cellSize / 2; double centerY = interpolated.dy * cellSize + cellSize / 2; // Move tail start point closer to the body segment centerX -= dx * cellSize * 0.4; // Move 10% of cell size closer centerY -= dy * cellSize * 0.4; // Calculate tail end point double endX = centerX + dx * tailLength; double endY = centerY + dy * tailLength; // Calculate tail side points double perpX = -dy * tailWidth; double perpY = dx * tailWidth; // Create tail path with a smoother connection to body Path tailPath = Path() ..moveTo(centerX + perpX * 0.8, centerY + perpY * 0.8) // Slightly narrower at base ..lineTo(endX, endY) ..lineTo(centerX - perpX * 0.8, centerY - perpY * 0.8) ..quadraticBezierTo( centerX, centerY, centerX + perpX * 0.8, centerY + perpY * 0.8, ); // Smooth connection canvas.drawPath(tailPath, snakePaint); } Offset _smoothLerp(Offset start, Offset end, double t) { double dx = end.dx - start.dx; double dy = end.dy - start.dy; // Handle wrapping around screen edges if (dx > squaresPerRow / 2) { dx -= squaresPerRow; } else if (dx < -squaresPerRow / 2) { dx += squaresPerRow; } if (dy > squaresPerCol / 2) { dy -= squaresPerCol; } else if (dy < -squaresPerCol / 2) { dy += squaresPerCol; } // Apply interpolation and handle wrapping double newX = (start.dx + dx * t) % squaresPerRow; double newY = (start.dy + dy * t) % squaresPerCol; // Ensure positive values if (newX < 0) newX += squaresPerRow; if (newY < 0) newY += squaresPerCol; return Offset(newX, newY); } Offset _getNextPosition(Offset current, Direction direction) { switch (direction) { case Direction.up: return Offset(current.dx, (current.dy - 1) % squaresPerCol); case Direction.right: return Offset((current.dx + 1) % squaresPerRow, current.dy); case Direction.down: return Offset(current.dx, (current.dy + 1) % squaresPerCol); case Direction.left: return Offset((current.dx - 1) % squaresPerRow, current.dy); } } void _drawGameItem(Canvas canvas, GameItem item) { final rect = Rect.fromLTWH( item.position.dx * cellSize, item.position.dy * cellSize, cellSize, cellSize, ); // Draw lifetime indicator final lifetimeProgress = item.remainingLifetimePercent; final indicatorRect = Rect.fromLTWH( rect.left, rect.bottom - 4, rect.width * lifetimeProgress, 4, ); canvas.drawRect( indicatorRect, Paint()..color = Colors.blue.withOpacity(0.7 * item.opacity), ); // Draw item background with opacity final itemPaint = Paint()..color = _getItemColor(item).withOpacity(item.opacity); canvas.drawRRect( RRect.fromRectAndRadius(rect, const Radius.circular(8)), itemPaint, ); // Draw special effects for bonuses with opacity if (item.type == ItemType.shield) { _drawShieldEffect(canvas, rect, itemPaint); } else if (item.type == ItemType.magnet) { _drawMagnetEffect(canvas, rect, itemPaint); } // Draw item content with opacity String content = _getItemContent(item); final textSpan = TextSpan( text: content, style: TextStyle( color: Colors.white.withOpacity(item.opacity), fontSize: _getItemFontSize(item), fontWeight: FontWeight.bold, ), ); final textPainter = TextPainter( text: textSpan, textDirection: TextDirection.ltr, textAlign: TextAlign.center, ); textPainter.layout(); textPainter.paint( canvas, Offset( rect.left + (rect.width - textPainter.width) / 2, rect.top + (rect.height - textPainter.height) / 2, ), ); } String _getItemContent(GameItem item) { switch (item.type) { case ItemType.shield: return '🛡️'; case ItemType.magnet: return '🧲'; default: return item.content; } } void _drawShieldEffect(Canvas canvas, Rect rect, Paint paint) { final center = rect.center; final radius = rect.width * 0.6; final effectPaint = Paint() ..color = paint.color.withOpacity(paint.color.opacity * 0.3) ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawCircle(center, radius, effectPaint); } void _drawMagnetEffect(Canvas canvas, Rect rect, Paint paint) { final center = rect.center; final size = rect.width * 0.3; final effectPaint = Paint() ..color = paint.color.withOpacity(paint.color.opacity * 0.5) ..style = PaintingStyle.stroke ..strokeWidth = 2; // Draw magnetic field lines for (int i = 0; i < 4; i++) { final angle = i * pi / 2; final dx = cos(angle) * size; final dy = sin(angle) * size; canvas.drawArc( Rect.fromCenter( center: center.translate(dx, dy), width: size, height: size, ), angle, pi, false, effectPaint, ); } } double _getItemFontSize(GameItem item) { switch (item.type) { case ItemType.letter: return cellSize * 0.8; case ItemType.word: return cellSize * 0.6; case ItemType.slowTime: case ItemType.health: return cellSize * 0.7; case ItemType.scoreMultiplier: return cellSize * 0.65; case ItemType.categoryChallenge: return cellSize * 0.5; case ItemType.shield: return cellSize * 0.7; case ItemType.magnet: return cellSize * 0.7; } } Color _getItemColor(GameItem item) { switch (item.type) { case ItemType.letter: case ItemType.word: return Colors.blue; case ItemType.slowTime: return Colors.purple; case ItemType.scoreMultiplier: return Colors.orange; case ItemType.categoryChallenge: return Colors.green; case ItemType.health: return Colors.pink; case ItemType.shield: return Colors.cyan; case ItemType.magnet: return Colors.amber; } } @override bool shouldRepaint(CustomPainter oldDelegate) => true; }