基于 WPF 的激动人心的乒乓球游戏
这是一款使用C#/WPF和Visual Studio编写的多人Pong游戏。
新更新:增加了屏幕震动效果
快速演示:点击这里
源代码:点击这里
描述
Pongs是一款在一周内开发的游戏,你可以使用W键向上移动球拍,使用A、S、D键(类似)和箭头键来击打球到另一侧。

图1 - 游戏进行的GIF动图
如果球越过你的屏幕一侧,对方就得一分。游戏将永远进行下去并记录你们的分数。

图2 - 游戏中的方向说明页
设置
这款游戏有一个设置页面。设置包括...

图3 - 游戏中的设置页面
- 球速 - 改变球的移动速度
- 球的大小 - 改变球的大小
- 球拍速度 - 改变球拍的移动速度
- 球拍大小 - 改变球拍的大小
- 获胜局数 - 玩家赢得游戏所需的胜利次数
球/球拍/墙壁/背景颜色 - 游戏中可以改变颜色的各种形状。在设置页面更改颜色会实时改变棋盘的颜色。
设置页面上的颜色选择器在改变游戏颜色时也会改变颜色。
这些特定的颜色使游戏看起来像这样。
还有暂停和重启动按钮,分别用于暂停游戏直到再次点击或重新开始游戏。

图4 - 游戏中的暂停和重启动按钮
代码解释
绘制棋盘
我代码的一个关键部分是在棋盘上绘制所有内容,例如球拍和球。这是通过三个不同的函数完成的。
public void DrawPaddle(Rectangle Paddle, double x, double y) { PaddleColor = new SolidColorBrush(SliderInfo.PaddleColor); Paddle.Width = 1.5 * SliderInfo.PaddleSize; Paddle.Height = 7.8 * SliderInfo.PaddleSize; [code deleted for brevity] Canvas.SetTop(Paddle, y); Canvas.SetLeft(Paddle, x); UpdateLocations("paddle1"); UpdateLocations("paddle2"); }
public void ReDraw() { [code deleted for brevity] if (WindowState == WindowState.Maximized) { Window.Height = (int)System.Windows.SystemParameters.PrimaryScreenHeight; Window.Width = (int)System.Windows.SystemParameters.PrimaryScreenWidth; } Board.Width = Window.Width; Board.Height = Window.Height; sbkGameEngine.y1 = Board.Height / 2 - (paddle1.Height / 2); sbkGameEngine.y2 = sbkGameEngine.y1; sbkGameEngine.x2 = Board.Width - 32; sbkGameEngine.x1 = 0; Ball.Width = 1.5 * SliderInfo.BallSize; Ball.Height = 1.5 * SliderInfo.BallSize; [code deleted for brevity] Canvas.SetTop(Ball, Board.Height / 2 - Ball.Height / 2); Canvas.SetLeft(Ball, Board.Width / 2 - Ball.Width / 2); UpdateLocations("ball"); Menu.Width = Board.Width; if (Board.Width - (SettingsMenu.Width + About.Width + Help.Width) - (1.5 * RestartButton.Width + PauseButton.Width) - 2 > 0) { Spacer.Width = Board.Width - (SettingsMenu.Width + About.Width + Help.Width) - (1.5 * RestartButton.Width + PauseButton.Width) - 2; } P1Scoreboard.Text = "" + sbkGameEngine.P1Score; P2Scoreboard.Text = "" + sbkGameEngine.P2Score; sbkGameEngine.CanBallMove = false; ReDrawUnmoving(); }
此函数绘制所有不动的形状,以及菜单和棋盘。这是为了初始设置棋盘以及在必要时重置棋盘。
private void ReDrawUnmoving() { Boundary.Stroke = WallColor; [code deleted for brevity] Boundary.Y1 = 0; Boundary.Y2 = Board.Height; BottomWall.Width = Board.Width; BottomWall.Height = 24; [code deleted for brevity] Canvas.SetTop(BottomWall, Board.Height - 63); Canvas.SetLeft(BottomWall, 0); Menu.BorderBrush = WallColor; Spacer.BorderBrush = WallColor; Menu.Background = WallColor; Spacer.Background = WallColor; Board.Background = BackgroundColor; }
此函数执行与ReDraw()相同的功能,只是这次绘制了不动的部分。
这些函数允许绘制棋盘,这是我游戏的一个关键元素。这是因为了解棋盘上所有元素的位置对于玩家做出正确的反应至关重要。
移动球
我游戏的另一个关键部分是球的移动。这是因为游戏的主要目的是试图阻止球进入你的场地区域。
public void BallMovement() { if (sbkGameEngine.CanBallMove && sbkGameEngine.GamePlayable) { Canvas.SetTop(Ball, Canvas.GetTop(Ball) + sbkGameEngine.VMovement); Canvas.SetLeft(Ball, Canvas.GetLeft(Ball) + sbkGameEngine.HMovement); UpdateLocations("ball"); } if (sbkGameEngine.P1Wins) { WhoWon_.Text = "Player 1 Wins!"; WhoWon_.Visibility = Visibility.Visible; RestartText.Visibility = Visibility.Visible; OnPause(Ball, a); sbkGameEngine.i = 2; } if (sbkGameEngine.P2Wins) { WhoWon_.Text = "Player 2 Wins!"; WhoWon_.Visibility = Visibility.Visible; RestartText.Visibility = Visibility.Visible; OnPause(Ball, a); sbkGameEngine.i = 2; } }
此函数仅使用sbkGameEngine类中的变量来移动球。它本身不做任何计算,只是按照sbkGameEngine通过更改变量告诉它的去做。
public void BallMovement() { log.Info("BallMovement Start"); if (GamePlayable) { int P1Top = Game.P1Up; int P1Bottom = Game.P1Down; int P1Left = Game.P1Left; int P1Right = Game.P1Right; int P2Top = Game.P2Up; int P2Bottom = Game.P2Down; int P2Left = Game.P2Left; int P2Right = Game.P2Right; int BallTop = Game.BallUp; int BallBottom = Game.BallDown; int BallLeft = Game.BallLeft; int BallRight = Game.BallRight; if ((P2Bottom > BallTop && P2Top < BallBottom && BallLeft < P2Right && BallRight > P2Left && HMovement == 1) || (P1Bottom > BallTop && P1Top < BallBottom && BallLeft < P1Right && BallRight > P1Left && HMovement == -1)) { HMovement *= -1; Console.Beep(37, 10); } if (BoundaryCheck(Game.Ball, 25, (int)(Game.Height - (Game.BottomWall.Height * 2) - 5), 0, 0, true, true, false, false) == false) { VMovement *= -1; Console.Beep(70, 5); } if (BallLeft >= mGuiReference.Board.Width) { P1Score++; Game.ReDraw(); log.Info("Player 1 scored!"); if (P1Score == SliderInfo.RoundsToWin) { P1Wins = true; } } if (BallLeft + 15 <= 0) { P2Score++; Game.ReDraw(); log.Info("Player 2 scored!"); if (P2Score == SliderInfo.RoundsToWin) { P2Wins = true; } } } log.Info("BallMovement End"); }
此函数负责实际进行计算。它会改变控制球移动方向的变量,当满足特定条件时,例如在碰到球拍后改变方向。
这两个函数结合在一起,实现了球的移动,并使其能够与其环境互动。
球拍移动
创建游戏的另一个必要元素是球拍的移动,因为它是唯一由玩家控制的形状。
public void OnKeyDown(object sender, KeyEventArgs e) { if (AllowedKeys.Contains(e.Key)) { if (i == 0) { KeysPressed.Add(e.Key); CanBallMove = true; } } }
public void OnKeyUp(object sender, KeyEventArgs e) { if (KeysPressed.Contains(e.Key)) { KeysPressed.Remove(e.Key); } }
public void PressedKeys(/*object? sender, EventArgs e*/) { if (GamePlayable) { if (KeysPressed.Contains(Key.Up) && BoundaryCheck(Game.paddle2, 25, (int)mGuiReference.Board.Height - 78, (int)mGuiReference.Board.Width / 2, (int)mGuiReference.Board.Width, true, false, false, false) == true) { y2 -= 2; } if (KeysPressed.Contains(Key.W) && BoundaryCheck(Game.paddle1, 25, (int)mGuiReference.Board.Height - 78, (int)mGuiReference.Board.Width / 2, (int)mGuiReference.Board.Width, true, false, false, false) == true) { y1 -= 2; } if (KeysPressed.Contains(Key.Down) && BoundaryCheck(Game.paddle2, 25, (int)mGuiReference.Board.Height - 78, (int)mGuiReference.Board.Width / 2, (int)mGuiReference.Board.Width, false, true, false, false) == true) { y2 += 2; } if (KeysPressed.Contains(Key.S) && BoundaryCheck(Game.paddle1, 25, (int)mGuiReference.Board.Height - 78, (int)mGuiReference.Board.Width / 2, (int)mGuiReference.Board.Width, false, true, false, false) == true) { y1 += 2; } if (KeysPressed.Contains(Key.Left) && BoundaryCheck(Game.paddle2, 25, (int)mGuiReference.Board.Height - 78, (int)mGuiReference.Board.Width / 2, (int)mGuiReference.Board.Width / 2, false, false, true, false) == true) { x2 -= 2; } if (KeysPressed.Contains(Key.A) && BoundaryCheck(Game.paddle1, 25, (int)mGuiReference.Board.Height - 78, 0, (int)mGuiReference.Board.Width / 2, false, false, true, false) == true) { x1 -= 2; } if (KeysPressed.Contains(Key.Right) && BoundaryCheck(Game.paddle2, 25, (int)mGuiReference.Board.Height - 78, 0, (int)mGuiReference.Board.Width - 20, false, false, false, true) == true) { x2 += 2; } if (KeysPressed.Contains(Key.D) && BoundaryCheck(Game.paddle1, 25, (int)mGuiReference.Board.Height - 78, 0, (int)mGuiReference.Board.Width / 2, false, false, false, true) == true) { x1 += 2; } } }
上述函数由另一个函数反复运行,并检查哈希集中是否有任何按键。如果哈希集中有按键,它将执行相应的移动,例如在哈希集中按下向上键时移动玩家2的球拍。
所有这些功能共同作用,使得玩家的按键输入能够对应到游戏中球拍所执行的操作。
设置页面
设置页面需要滑块,当滑块变化时会改变变量,以便实现滑块所指示的操作。这是使用XAML和数据绑定完成的。
public static double BallSpeed { get; set; } public static double BallSize { get; set; } public static Color PaddleColor { get; set; } public static Color WallColor { get; set; }
slider info类包含一些滑块要操作的变量。这些变量将是滑动滑块时会改变的变量。
<Slider x:Name="BallSpeedSlider" Margin="136,72,0,0" Maximum="9" Minimum="2" IsSnapToTickEnabled="True" Value="{Binding BallSpeed, Mode=TwoWay}" ValueChanged="OnValueChanged" HorizontalAlignment="Left" Width="226" Height="123" VerticalAlignment="Top" Grid.ColumnSpan="2"/> <xctk:ColorPicker x:Name="Wall_Color_Picker" Margin="207,339,0,0" Height="23" VerticalAlignment="Top" HorizontalAlignment="Left" Width="101" ShowDropDownButton = "False" ShowTabHeaders="False" ColorMode="ColorCanvas" SelectedColor="{Binding WallColor, Mode=TwoWay}" Grid.Column="1"/>
以上是在XAML中制作的滑块和颜色选择器的示例,它们通过数据绑定改变变量。数据绑定将滑块的值绑定到变量,并将绑定的模式设置为TwoWay,这样当滑块被改变时,变量也会改变,反之亦然。
这些变量连接到诸如球速或球拍颜色之类的属性,这样当滑块或颜色选择器被更改时,属性也会随之改变。这使得设置页面能够以一种不复杂的方式运行。
数据绑定
SelectedColor="{Binding WallColor, Mode=TwoWay}"
以上是XAML中颜色绑定的示例。SelectedColor是颜色选择器中选择的颜色。您可以看到它绑定到WallColor,因为在大括号中写着Binding WallColor。WallColor可以替换为任何其他变量,颜色选择器将绑定到该变量,前提是该变量接受颜色输入。Mode为TwoWay允许颜色选择器的更改改变变量,变量的更改改变颜色选择器。数据绑定有4种类型。
1. OneWay - 只有源属性(例如颜色选择器或滑块)的变化会影响变量。
2. TwoWay - 变量的变化会影响源属性,源属性的变化也会影响变量。
3. OneWayToSource - 只有变量的变化会影响源属性。
4. OneTime - 在使用变量初始化应用程序时,仅更新源属性一次。
这4种类型都有其各自的用例,但我在我的项目中只使用了TwoWay,因为其他类型对于我的游戏功能来说都不是必需的。
屏幕震动
当玩家得分时通过摇晃屏幕,可以提供视觉反馈,让他们感受到更高的成就感。虽然一开始看起来编码很容易,但它也带来了一些挑战。
为了实现这种效果,我使用了故事板,因为它是一种简单的动画播放方式。故事板看起来是这样的。
<Storyboard RepeatBehavior="0:0:1" Name="DownRight"> <DoubleAnimation Storyboard.TargetName="Window" Storyboard.TargetProperty="(Window.Left)" From="{Binding WindowLeft, Mode=TwoWay}" To="{Binding Path=WindowLeftTo, Mode=TwoWay}" Duration="0:0:0:0.03" BeginTime="0:0:0" AutoReverse="true" RepeatBehavior="3x" FillBehavior="Stop"/> <DoubleAnimation Storyboard.TargetName="Window" Storyboard.TargetProperty="(Window.Top)" From="{Binding WindowTop, Mode=TwoWay}" To="{Binding Path=WindowTopTo, Mode=TwoWay}" Duration="0:0:0:0.03" BeginTime="0:0:0" AutoReverse="true" RepeatBehavior="3x" FillBehavior="Stop"/> </Storyboard>
这个故事板使用两个双精度动画使窗口上下和左右移动。双精度动画需要From(窗口动画的起始位置)和To(窗口动画的结束位置)输入。我使用了窗口的left和top值,To则使用了比窗口left值+10和top值+10的变量。
要获取这些值,您可能会认为可以使用“Application.Current.MainWindow.Left”和“Application.Current.MainWindow.Top”。但是,由于WPF的工作方式,如果窗口从未移动过,它将返回NaN。这很重要,因为如果窗口从未移动过,它会导致动画运行时崩溃。因此,我不得不使用另一种方法来获取窗口的坐标。
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); public event PropertyChangedEventHandler PropertyChanged;
这段代码创建了一个矩形,该矩形获取了窗口的坐标。这样就可以找到矩形的坐标,与窗口的坐标相同。并用作故事板中的from和to参数。
当窗口打开或移动时,为了获取窗口的新坐标,它会调用以下函数。
private void GetNewLocation() { RECT rect; IntPtr windowHandle = new WindowInteropHelper(Window).Handle; GetWindowRect(Process.GetCurrentProcess().MainWindowHandle, out rect); WindowLeft = rect.Left; WindowTop = rect.Top; PropertyChanged(this, new PropertyChangedEventArgs(nameof(WindowLeft))); PropertyChanged(this, new PropertyChangedEventArgs(nameof(WindowLeftTo))); PropertyChanged(this, new PropertyChangedEventArgs(nameof(WindowTop))); PropertyChanged(this, new PropertyChangedEventArgs(nameof(WindowTopTo))); }
此函数使用窗口的新属性创建矩形,然后更改XAML中对应于更改的变量的值。最终结果如下所示。
气球
游戏结束后,气球会从地面飞起。为了实现这种效果,使用了几个新方法。
Thread CreateBalloons = new Thread(BalloonRunner); CreateBalloons.Start(); public void BalloonRunner() { sbkGameEngine.BalloonRun = true; while (sbkGameEngine.BalloonRun) { if (Balloons) { Dispatcher.Invoke(() => CreateBalloon() ); } Dispatcher.Invoke(() => MoveBalloons() ); Thread.SpinWait(1000000); } }
此线程调用BalloonRunner方法,该方法会反复调用CreateBalloon和MoveBalloon。但是,只有当Balloon布尔值为true时,它才会调用CreateBalloon,而这只有在游戏结束后才会设置。
Interval -= 10; if (Interval < 1) { ... Interval = RandomInt.Next(90, 150); }
上面的代码使用Int Interval来控制代码的执行频率,使其每隔几次调用才执行一次。这样可以防止气球过快地填满屏幕,并使它们有足够的时间。
if (NumberOfBalloons == 99) { Balloons = false; }
此代码检查NumberOfBalloons变量是否超过99,如果是,则通过禁用运行器来结束线程。每次创建气球时,NumberOfBalloons会增加一。
int BalloonColor = RandomInt.Next(1, 6); switch (BalloonColor) { case 1: BalloonImage.ImageSource = new BitmapImage(new Uri("pack://application:,,,/PongsSBK;component/pictures/RedBalloon.png")); break; case 2: BalloonImage.ImageSource = new BitmapImage(new Uri("pack://application:,,,/PongsSBK;component/pictures/OrangeBalloon.png")); break; case 3: BalloonImage.ImageSource = new BitmapImage(new Uri("pack://application:,,,/PongsSBK;component/pictures/YellowBalloon.png")); break; case 4: BalloonImage.ImageSource = new BitmapImage(new Uri("pack://application:,,,/PongsSBK;component/pictures/GreenBalloon.png")); break; case 5: BalloonImage.ImageSource = new BitmapImage(new Uri("pack://application:,,,/PongsSBK;component/pictures/BlueBalloon.png")); break; }
代码使用switch语句替换了5个if语句。RandomInt生成一个1-5的随机整数,然后switch语句检查它获得了哪个数字,并将相应的气球颜色添加到画笔BalloonColor中。
Rectangle NewBalloon = new Rectangle { Tag = "Balloon", Width = 37, Height = 47, Fill = BalloonImage }; Canvas.SetTop(NewBalloon, (int)((System.Windows.Controls.Panel)Application.Current.MainWindow.Content).ActualHeight); Canvas.SetLeft(NewBalloon, RandomInt.Next(0, (int)((System.Windows.Controls.Panel)Application.Current.MainWindow.Content).ActualWidth) - (NewBalloon.Width / 2)); Board.Children.Add(NewBalloon);
此代码使用气球画笔创建矩形,这样矩形就可以显示气球的图像,从而形成一个气球。
private void MoveBalloons() { foreach (var x in Board.Children.OfType<Rectangle>()) { if ((string)x.Tag == "Balloon") { Canvas.SetTop(x, Canvas.GetTop(x) - speed); Canvas.SetLeft(x, Canvas.GetLeft(x) - (1 * RandomInt.Next(-1, 2))); } if (Canvas.GetTop(x) < 0 - x.Height) { itemRemover.Add(x); } } foreach (Rectangle x in itemRemover) { Board.Children.Remove(x); } }
MoveBalloons方法使用foreach循环来移动每个气球。它首先检查每个矩形是否具有“balloon”标签,只有气球才会有这个标签,然后将其向上并随机向左移动。一旦气球超过一定高度,它就会被添加到list itemRemover中。下一个foreach函数检查每个矩形是否位于窗口顶部,如果是则将其删除。
所有这些操作共同作用,使得游戏结束后气球会飞起来。当一方获得一定分数(默认为5分)时,游戏结束。
运行应用程序
- 访问GitHub链接:点击这里
- 前往标记为“Quick Demo”的文件夹
- 下载“ZippedDemo.zip”
- 解压文件
- 运行Pongs.exe
历史
v1.0 -- 2024年5月5日 - 第一个版本
v1.1 -- 2024年5月22日 - 修改了摘要
v1.2 -- 2024年5月23日 - 更新了运行应用程序的步骤
v1.3 -- 2024年5月24日 - 更新了代码
v1.4 -- 2024年5月27日 - 添加了设置标题
v1.5 -- 2024年5月29日 - 添加了图号和描述
v1.6 -- 2024年5月31日 - 修复了之前无法加载的gif
v2.0 -- 2024年6月2日 - 在设置中添加了颜色选择器,并更新了代码、设置和代码说明。
v2.1 -- 2024年6月9日 - 使代码段更加简洁
v2.2 -- 2024年6月16日 - 添加了屏幕震动效果,并增加了代码说明。
v2.3 -- 2024年7月19日 - 添加了用于替换GetLeft和GetTop函数的变量
参考文献
2. https://learn.microsoft.com/en-us/dotnet/
如果您觉得这篇文章有帮助/有趣,请不要忘记投票!