mnemo_cards/games/apps/mnemo_cards_snake_game/lib/main.dart
2025-11-19 21:00:23 +03:00

874 lines
26 KiB
Dart

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<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen>
with SingleTickerProviderStateMixin {
static const int squaresPerRow = 12;
static const int squaresPerCol = 16;
late double cellSize;
final List<Offset> 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<Offset> snakePositions;
final List<GameItem> gameItems;
final double cellSize;
final double animationValue;
final Direction direction;
final List<PowerUp> 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<Offset> eyePositions = [];
List<Offset> 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;
}