874 lines
26 KiB
Dart
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;
|
|
}
|