Created
June 24, 2025 15:58
-
-
Save Piinks/6867c8ca81bf939c46e0cbfe2089ab99 to your computer and use it in GitHub Desktop.
One-sided Pong
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:provider/provider.dart'; | |
| import 'dart:async'; // For Timer | |
| /// The GameModel manages the entire state of the Pong game. | |
| /// It extends ChangeNotifier to notify its listeners (the UI) of any state changes. | |
| class GameModel extends ChangeNotifier { | |
| // Game dimensions, initialized to a default and updated by LayoutBuilder | |
| double _gameAreaWidth = 300.0; | |
| double _gameAreaHeight = 600.0; | |
| // Ball properties | |
| Offset _ballPosition = Offset.zero; | |
| Offset _ballVelocity = Offset.zero; | |
| static const double _ballRadius = 10.0; | |
| static const double _initialBallSpeed = 300.0; // Pixels per second | |
| // Paddle properties | |
| double _paddleX = 0; | |
| static const double _paddleWidth = 100.0; | |
| static const double _paddleHeight = 15.0; | |
| static const double _paddleYOffset = 50.0; // Distance from bottom edge of game area | |
| // Game state | |
| int _score = 0; | |
| bool _isGameOver = false; | |
| /// Constructor initializes the game state. | |
| GameModel() { | |
| _resetGameState(); | |
| } | |
| // Getters for UI to access game state | |
| Offset get ballPosition => _ballPosition; | |
| double get ballRadius => _ballRadius; | |
| double get paddleX => _paddleX; | |
| double get paddleWidth => _paddleWidth; | |
| double get paddleHeight => _paddleHeight; | |
| double get paddleY => _gameAreaHeight - _paddleYOffset - _paddleHeight; // Fixed Y position for paddle | |
| int get score => _score; | |
| bool get isGameOver => _isGameOver; | |
| /// Sets the dimensions of the game area. | |
| /// If dimensions change, the game state is reset to adapt to the new size. | |
| void setGameAreaSize(double width, double height) { | |
| if (_gameAreaWidth != width || _gameAreaHeight != height) { | |
| _gameAreaWidth = width; | |
| _gameAreaHeight = height; | |
| _resetGameState(); // Reset positions if size changes | |
| notifyListeners(); | |
| } | |
| } | |
| /// Resets the game to its initial state. | |
| void _resetGameState() { | |
| _isGameOver = false; | |
| _score = 0; | |
| // Initial ball position at center | |
| _ballPosition = Offset(_gameAreaWidth / 2, _gameAreaHeight / 2); | |
| // Initial ball velocity (e.g., down-right) | |
| _ballVelocity = const Offset(_initialBallSpeed, _initialBallSpeed); | |
| // Initial paddle position centered at the bottom | |
| _paddleX = (_gameAreaWidth - _paddleWidth) / 2; | |
| notifyListeners(); | |
| } | |
| /// Starts or restarts the game. | |
| void startGame() { | |
| if (_isGameOver) { | |
| _resetGameState(); | |
| } | |
| } | |
| /// Sets the paddle's horizontal position. | |
| /// The paddle's movement is clamped within the game area boundaries. | |
| void setPaddleX(double newX) { | |
| if (_isGameOver) return; | |
| _paddleX = newX.clamp(0.0, _gameAreaWidth - _paddleWidth); | |
| notifyListeners(); | |
| } | |
| /// The main game loop update function. | |
| /// It calculates new ball position, handles collisions, updates score, and checks for game over. | |
| /// `delta` is the time elapsed since the last update. | |
| void update(Duration delta) { | |
| if (_isGameOver) return; | |
| final double dt = delta.inMicroseconds / 1000000.0; // Delta time in seconds | |
| // Update ball position based on velocity and delta time | |
| _ballPosition = _ballPosition + _ballVelocity * dt; | |
| // --- Ball-wall collision detection --- | |
| // Left wall | |
| if (_ballPosition.dx - _ballRadius < 0) { | |
| _ballPosition = Offset(_ballRadius, _ballPosition.dy); // Adjust position to prevent sticking | |
| _ballVelocity = Offset(-_ballVelocity.dx, _ballVelocity.dy); // Reverse X velocity | |
| } | |
| // Right wall | |
| else if (_ballPosition.dx + _ballRadius > _gameAreaWidth) { | |
| _ballPosition = Offset(_gameAreaWidth - _ballRadius, _ballPosition.dy); // Adjust position | |
| _ballVelocity = Offset(-_ballVelocity.dx, _ballVelocity.dy); // Reverse X velocity | |
| } | |
| // Top wall | |
| if (_ballPosition.dy - _ballRadius < 0) { | |
| _ballPosition = Offset(_ballPosition.dx, _ballRadius); // Adjust position | |
| _ballVelocity = Offset(_ballVelocity.dx, -_ballVelocity.dy); // Reverse Y velocity | |
| } | |
| // --- Ball-paddle collision detection --- | |
| final double ballBottom = _ballPosition.dy + _ballRadius; | |
| final double paddleTop = _gameAreaHeight - _paddleYOffset - _paddleHeight; | |
| final double paddleLeft = _paddleX; | |
| final double paddleRight = _paddleX + _paddleWidth; | |
| // Check if ball is moving downwards, is at or below the paddle's top edge, | |
| // and horizontally overlaps with the paddle. | |
| if (_ballVelocity.dy > 0 && // Ball is moving downwards | |
| ballBottom >= paddleTop && // Ball's bottom edge touches or passed paddle's top edge | |
| _ballPosition.dy < paddleTop && // Ball's center is still above paddle's top edge (prevents re-collision) | |
| _ballPosition.dx + _ballRadius > paddleLeft && // Ball's right edge passed paddle's left edge | |
| _ballPosition.dx - _ballRadius < paddleRight) // Ball's left edge passed paddle's right edge | |
| { | |
| // Collision with paddle | |
| _ballPosition = Offset(_ballPosition.dx, paddleTop - _ballRadius); // Adjust position to prevent sticking | |
| _ballVelocity = Offset(_ballVelocity.dx, -_ballVelocity.dy); // Reverse Y velocity | |
| _score++; // Increase score | |
| } | |
| // --- Game over condition --- | |
| // If the ball goes below the paddle's Y position | |
| if (_ballPosition.dy + _ballRadius > _gameAreaHeight - _paddleYOffset + 10) { // Add a small buffer | |
| _isGameOver = true; | |
| } | |
| notifyListeners(); // Notify UI to rebuild with new state | |
| } | |
| } | |
| /// The root widget of the Pong game application. | |
| class PongGameApp extends StatelessWidget { | |
| const PongGameApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'Paddle Pong', | |
| theme: ThemeData.dark().copyWith( | |
| scaffoldBackgroundColor: Colors.black, // Dark background for the game | |
| appBarTheme: const AppBarTheme( | |
| backgroundColor: Colors.black, | |
| elevation: 0, | |
| foregroundColor: Colors.white, | |
| ), | |
| ), | |
| home: ChangeNotifierProvider<GameModel>( | |
| create: (BuildContext context) => GameModel(), | |
| builder: (BuildContext context, Widget? child) => const GameScreen(), | |
| ), | |
| ); | |
| } | |
| } | |
| /// The main game screen widget, responsible for rendering the game elements | |
| /// and handling user input. | |
| class GameScreen extends StatefulWidget { | |
| const GameScreen({super.key}); | |
| @override | |
| State<GameScreen> createState() => _GameScreenState(); | |
| } | |
| /// State for the GameScreen, includes AnimationController for game loop. | |
| class _GameScreenState extends State<GameScreen> with SingleTickerProviderStateMixin { | |
| late AnimationController _animationController; | |
| late GameModel _gameModel; // Reference to the GameModel instance | |
| DateTime _lastFrameTime = DateTime.now(); // To calculate delta time for game loop | |
| // Keep track of the last reported dimensions to prevent redundant calls | |
| double? _lastReportedWidth; | |
| double? _lastReportedHeight; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| // Initialize AnimationController for the game loop. | |
| // A long duration combined with .repeat() makes it run continuously. | |
| _animationController = AnimationController( | |
| duration: const Duration(days: 1), // Effectively infinite duration | |
| vsync: this, | |
| )..addListener(() { | |
| final DateTime now = DateTime.now(); | |
| final Duration delta = now.difference(_lastFrameTime); | |
| _lastFrameTime = now; // Update for the next frame | |
| // Update the game model with the elapsed time. | |
| // listen: false because we are only modifying the model, not listening for rebuilds here. | |
| Provider.of<GameModel>(context, listen: false).update(delta); | |
| }); | |
| // Ensure _gameModel is initialized after the context is available. | |
| // This allows us to access the Provider. | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| // It's safe to access context here because this callback runs after the first build. | |
| _gameModel = Provider.of<GameModel>(context, listen: false); | |
| _animationController.repeat(); // Start the game loop | |
| }); | |
| } | |
| @override | |
| void dispose() { | |
| _animationController.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| appBar: AppBar( | |
| title: const Text('Paddle Pong'), | |
| ), | |
| body: MouseRegion( | |
| onHover: (PointerHoverEvent event) { | |
| // Only allow paddle movement if game is not over | |
| if (!_gameModel.isGameOver) { | |
| // Calculate the desired paddle X position, centering the paddle under the mouse | |
| final double newPaddleX = event.localPosition.dx - (_gameModel.paddleWidth / 2); | |
| _gameModel.setPaddleX(newPaddleX); | |
| } | |
| }, | |
| child: LayoutBuilder( | |
| builder: (BuildContext context, BoxConstraints constraints) { | |
| // Defer setting the game area size to avoid calling notifyListeners | |
| // during the build phase. This ensures the framework finishes | |
| // building the current frame before the model changes and requests | |
| // another rebuild. | |
| if (constraints.maxWidth != _lastReportedWidth || constraints.maxHeight != _lastReportedHeight) { | |
| _lastReportedWidth = constraints.maxWidth; | |
| _lastReportedHeight = constraints.maxHeight; | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| // Ensure the widget is still mounted before accessing context and model | |
| if (mounted) { | |
| Provider.of<GameModel>(context, listen: false) | |
| .setGameAreaSize(constraints.maxWidth, constraints.maxHeight); | |
| } | |
| }); | |
| } | |
| // Use Consumer to rebuild the UI when GameModel notifies changes. | |
| return Consumer<GameModel>( | |
| builder: (BuildContext context, GameModel game, Widget? child) { | |
| return Stack( | |
| children: <Widget>[ | |
| // The Ball | |
| Positioned( | |
| left: game.ballPosition.dx - game.ballRadius, | |
| top: game.ballPosition.dy - game.ballRadius, | |
| child: Container( | |
| width: game.ballRadius * 2, | |
| height: game.ballRadius * 2, | |
| decoration: const BoxDecoration( | |
| color: Colors.white, | |
| shape: BoxShape.circle, | |
| ), | |
| ), | |
| ), | |
| // The Paddle | |
| Positioned( | |
| left: game.paddleX, | |
| top: game.paddleY, | |
| child: Container( | |
| width: game.paddleWidth, | |
| height: game.paddleHeight, | |
| decoration: BoxDecoration( | |
| color: Colors.lightBlueAccent, | |
| borderRadius: BorderRadius.circular(5), | |
| ), | |
| ), | |
| ), | |
| // Score display | |
| Positioned( | |
| top: 20, | |
| left: 0, | |
| right: 0, | |
| child: Text( | |
| 'Score: ${game.score}', | |
| textAlign: TextAlign.center, | |
| style: const TextStyle( | |
| fontSize: 24, | |
| fontWeight: FontWeight.bold, | |
| color: Colors.white70, | |
| ), | |
| ), | |
| ), | |
| // Game Over overlay | |
| if (game.isGameOver) | |
| Positioned.fill( | |
| child: Container( | |
| color: Colors.black54, // Semi-transparent overlay | |
| child: Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: <Widget>[ | |
| const Text( | |
| 'Game Over!', | |
| style: TextStyle( | |
| fontSize: 48, | |
| fontWeight: FontWeight.bold, | |
| color: Colors.redAccent, | |
| ), | |
| ), | |
| const SizedBox(height: 20), | |
| Text( | |
| 'Final Score: ${game.score}', | |
| style: const TextStyle( | |
| fontSize: 32, | |
| fontWeight: FontWeight.bold, | |
| color: Colors.white, | |
| ), | |
| ), | |
| const SizedBox(height: 40), | |
| ElevatedButton( | |
| onPressed: () { | |
| game.startGame(); // Restart the game | |
| }, | |
| style: ElevatedButton.styleFrom( | |
| padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 20), | |
| backgroundColor: Colors.green, // Distinct button color | |
| shape: RoundedRectangleBorder( | |
| borderRadius: BorderRadius.circular(10), | |
| ), | |
| ), | |
| child: const Text( | |
| 'Play Again', | |
| style: TextStyle(fontSize: 24, color: Colors.white), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| }, | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// The entry point of the Flutter application. | |
| void main() { | |
| runApp(const PongGameApp()); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment