Snail Quest - 一个使用WPF、A*搜索算法、C# Midi Toolkit和Irrklang音频引擎的迷宫游戏






4.99/5 (68投票s)
本文涵盖了从WPF动画到使用两个声音引擎集成音乐的迷宫游戏的创建。
目录
- 引言
- 系统要求
- 游戏
- 启动画面:气泡动画
- 创建迷宫:从文本文件到WPF
- 蜗牛角色
- 鱿鱼角色
- 游戏控制键
- 收集海星
- 投掷海星
- 杀死鱿鱼
- 被鱿鱼追赶(使用A*搜索算法)
- 被鱿鱼杀死
- 收集珍珠
- 游戏得分
- 进入下一关
- 使用C# Midi Toolkit播放游戏音乐
- 使用IrrKlang引擎播放音效
- 最终思考
- 历史
引言
在80年代,我还是个小男孩的时候,我曾经玩过一款很棒的电子游戏,一个黄色的球形角色在一个迷宫里移动,吃掉小方块,并躲避4种颜色的幽灵。
多年以后,我终于想出了一个游戏,它并不打算像原版的《吃豆人》那样出色,而是受到它的启发。
至于游戏名称,我觉得很有趣,因为它听起来像一些经典的PC游戏,比如“Space Quest”、“King's Quest”。
尽管游戏概念背后有着有趣的氛围,但本文和这里的游戏旨在展示如何将不同的技术混合在一起,以提供吸引人的视觉和听觉体验。
系统要求
要使用本文提供的Snail Quest游戏,如果您已经有了Visual Studio 2010,那就足够了。如果没有,您可以直接从Microsoft下载以下100%免费的开发工具。
- Visual C# 2010 Express
此外,您必须通过点击DevLabs页面上同名按钮,下载并安装Rx for .Net Framework 4.0。
- DevLabs:.NET的响应式扩展(Rx)
YouTube视频
在继续阅读本文的其余部分之前,您可能此刻对游戏是如何运作的好奇,所以我为您上传了一个视频。
游戏规则
游戏规则很简单:您必须在迷宫中移动(或爬行)您的蜗牛并收集所有的珍珠,同时躲避四只鱿鱼。
一旦您收集完迷宫中的所有珍珠,您将进入下一个关卡,一个不同的迷宫,您也将在其中收集珍珠。
当您完成最后一关时,您会看到一个祝贺消息,游戏结束。
启动画面:气泡动画
游戏开头用酷炫的“Jokerman”字体显示游戏标题。起初我想用“Comic Sans”,但我认为Jokerman更合适。
如您所见,此屏幕在背景中显示了一些气泡。这些气泡由一个函数动态生成,并使用WPF动画进行动画处理。有10个固定的气泡,它们垂直动画,每个气泡具有不同的直径和速度。第二个动画应用于气泡的透明度,使它们突然出现并在到达屏幕顶部时消失在深海中。
动画会一直进行下去,永不停止。这是通过将`Storyboard`类的`RepeatBehavior`设置为`RepeatBehavior.Forever`值来实现的。
private void CreateBubbles() { Storyboard sbPressSpace = this.FindResource("sbPressSpace") as Storyboard; sbPressSpace.Begin(); var linearBubbleBrush = new LinearGradientBrush() { StartPoint = new Point(1, 0), EndPoint = new Point(0, 1) }; linearBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0xFF, 0x00, 0x20, 0x40), 0.0)); linearBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF), 1.0)); var radialBubbleBrush = new RadialGradientBrush() { Center = new Point(0.25, 0.75), RadiusX = .3, RadiusY = .2, GradientOrigin = new Point(0.35, 0.75) }; radialBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF), 0.0)); radialBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF), 1.0)); for (var i = 0; i < 10; i++) { var diameter = 10 + (i % 4) * 10; var ms = 1000 + i % 7 * 500; var ellBubble = new Ellipse() { Width = diameter, Height = diameter, Stroke = linearBubbleBrush, Fill = radialBubbleBrush, StrokeThickness = 3 }; ellBubble.SetValue(Canvas.LeftProperty, i * (40.0 + 40.0 - diameter / 2)); ellBubble.SetValue(Canvas.TopProperty, 0.0 + 40.0 - diameter / 2); cnvBubbles.Children.Add(ellBubble); var leftAnimation = new DoubleAnimation() { From = 40.0 * i, To = 40.0 * i, Duration = TimeSpan.FromMilliseconds(ms) }; var topAnimation = new DoubleAnimation() { From = 200, To = 0, Duration = TimeSpan.FromMilliseconds(ms) }; var opacityAnimation = new DoubleAnimation() { From = 1.0, To = 0.0, Duration = TimeSpan.FromMilliseconds(ms) }; Storyboard.SetTarget(leftAnimation, ellBubble); Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)")); Storyboard.SetTarget(topAnimation, ellBubble); Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)")); Storyboard.SetTarget(opacityAnimation, ellBubble); Storyboard.SetTargetProperty(opacityAnimation, new PropertyPath("Opacity")); leftAnimation.EasingFunction = new BackEase() { Amplitude = 0.5, EasingMode = EasingMode.EaseOut }; topAnimation.EasingFunction = new BackEase() { Amplitude = 0.5, EasingMode = EasingMode.EaseOut }; var sb = new Storyboard(); sb.Children.Add(leftAnimation); sb.Children.Add(topAnimation); sb.Children.Add(opacityAnimation); sb.RepeatBehavior = RepeatBehavior.Forever; bubbles.Add(ellBubble); storyBoards.Add(sb); sb.Begin(); } }
创建迷宫:从文本文件到WPF
迷宫是动态创建的,来自纯文本文件。每个文本文件都必须是矩形的,大小为15 x 10个字符,其中不同的字符具有不同的含义。
- 1 - 表示构成迷宫墙壁的玻璃块。每个块的形状可能不同,取决于相邻单元格的值。
- [空格] - 空格允许蜗牛和鱿鱼在迷宫通道内自由行走。
- A, B, C和D - 分别是红色、黄色、白色和蓝色鱿鱼的初始位置。
- o - 每一个珍珠的位置。
- * - 每一个海星的位置。
- S - 蜗牛的初始位置。
创建一个关卡编辑器本来会很好,但我认为这不是游戏的重点。相反,您可以使用普通的文本编辑器来完成。
private void LoadMaze(int level) { collectedPearls.Clear(); collectedStarfishes.Clear(); grdMaze.Children.Clear(); for (var i = 0; i < starfishes.Count(); i++) { cnvMain.Children.Remove(starfishes[i]); } starfishes.Clear(); for (var i = 0; i < pearls.Count(); i++) { cnvMain.Children.Remove(pearls[i]); } pearls.Clear(); for (var i = 0; i < mazeGlasses.GetLength(0); i++) { for (var j = 0; j < mazeGlasses.GetLength(1); j++) { mazeGlasses[i, j] = null; } } for (var i = 0; i < mazeValues.GetLength(0); i++) { for (var j = 0; j < mazeValues.GetLength(1); j++) { mazeValues[i, j] = ' '; } } var fileName = string.Format(@"Mazes\Level{0}.txt", level); using (var sr = new StreamReader(fileName)) { var l = 0; while (!sr.EndOfStream) { string line = sr.ReadLine(); for (var c = 0; c < line.Length; c++) { mazeValues[c, l] = line[c]; if (mazeValues[c, l] == '1') { var glass = new Glass(); glass.SetValue(Grid.ColumnProperty, c); glass.SetValue(Grid.RowProperty, l); grdMaze.Children.Add(glass); mazeGlasses[c, l] = glass; } else if (mazeValues[c, l] == '*') { var starfish = new Starfish(); starfish.SetValue(Canvas.LeftProperty, 0.0); starfish.SetValue(Canvas.TopProperty, 0.0); starfish.SetValue(Canvas.ZIndexProperty, -1); cnvMain.Children.Add(starfish); starfish.Throw(new Point(c, l), new Point(c, l), TimeSpan.FromMilliseconds(50), null); starfishes.Add(starfish); } else if (mazeValues[c, l] == 'o') { var pearl = new Pearl() { Width = 30, Height = 30 }; pearl.SetValue(Canvas.LeftProperty, 0.0); pearl.SetValue(Canvas.TopProperty, 0.0); pearl.SetValue(Canvas.ZIndexProperty, -1); cnvMain.Children.Add(pearl); pearl.PlaceAt(new Point(c, l)); pearls.Add(pearl); } else if (mazeValues[c, l] == 'S') { snail.OriginalCellPoint = new Point(c, l); } else if ("ABCD".Contains(mazeValues[c, l])) { var index = "ABCD".IndexOf(mazeValues[c, l]); squids[index].OriginalCellPoint = new Point(c, l); } } l++; } } for (var c = 0; c < mazeWidth; c++) { for (var l = 0; l < mazeHeight; l++) { var topValue = ' '; var bottomValue = ' '; var leftValue = ' '; var rightValue = ' '; if (l > 0) topValue = mazeValues[c, l - 1]; if (l < mazeHeight - 1) bottomValue = mazeValues[c, l + 1]; if (c > 0) leftValue = mazeValues[c - 1, l]; if (c < mazeWidth - 1) rightValue = mazeValues[c + 1, l]; var glass = mazeGlasses[c, l]; if (glass != null) { glass.LeftValue = leftValue; glass.RightValue = rightValue; glass.TopValue = topValue; glass.BottomValue = bottomValue; } } } }
迷宫的每个块都由玻璃组成,因此使用`Glass`用户控件。这个用户控件有9个部分,其中心部分可以填充或不填充,具体取决于是否有相邻的块。我们通过为用户控件分配依赖属性来实现这一点。
public partial class Glass : UserControl { #region DPs private static DependencyProperty LeftValueProperty = DependencyProperty.Register("Left", typeof(char), typeof(Glass), new PropertyMetadata(LeftValueChanged)); public char LeftValue { get { return (char)this.GetValue(LeftValueProperty); } set {this.SetValue(LeftValueProperty, value);} } static void LeftValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var glass = (Glass)d; glass.rct01.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden; } private static DependencyProperty RightValueProperty = DependencyProperty.Register("Right", typeof(char), typeof(Glass), new PropertyMetadata(RightValueChanged)); public char RightValue { get { return (char)this.GetValue(RightValueProperty); } set { this.SetValue(RightValueProperty, value); } } static void RightValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var glass = (Glass)d; glass.rct21.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden; } private static DependencyProperty TopValueProperty = DependencyProperty.Register("Top", typeof(char), typeof(Glass), new PropertyMetadata(TopValueChanged)); public char TopValue { get { return (char)this.GetValue(TopValueProperty); } set { this.SetValue(TopValueProperty, value); } } static void TopValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var glass = (Glass)d; glass.rct10.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden; } private static DependencyProperty BottomValueProperty = DependencyProperty.Register("Bottom", typeof(char), typeof(Glass), new PropertyMetadata(BottomValueChanged)); public char BottomValue { get { return (char)this.GetValue(BottomValueProperty); } set { this.SetValue(BottomValueProperty, value); } } static void BottomValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var glass = (Glass)d; glass.rct12.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden; } #endregion DPs
为了稍微澄清一下它是如何工作的:如果您有一个单独的块在迷宫的中间,它在屏幕上看起来只是一个小块。但如果您在它旁边放置一个块,您不会看到两个块,而是看到一堵墙,从一个块延伸到另一个块。根据相邻块的情况,您还可以拥有“L”形、“T”形或十字形的布局。
蜗牛角色
蜗牛是我们的英雄,而且不知何故,鱿鱼不喜欢他(抱歉,我对海洋动物的行为不太了解)。而且,我们的英雄必须收集迷宫中的所有珍珠才能完成每个关卡。
大多数电子游戏英雄,如马里奥和索尼克,都很有魅力。我们的蜗牛也不例外。他眨眼、微笑,再眨眼、微笑。而且,当他死去时,他会淡入淡出,并露出一个痛苦的表情。这些情感旨在激发玩家的同情心和与英雄的认同感。
<Storyboard x:Name="sbBlink" x:Key="sbBlink" Duration="0:0:3" RepeatBehavior="Forever" AutoReverse="True" FillBehavior="HoldEnd"> <DoubleAnimation Storyboard.TargetName="leftPupil" Storyboard.TargetProperty="Height" From="7" To="1" Duration="0:0:0.200" BeginTime="0:0:0.000" FillBehavior="HoldEnd"/> <DoubleAnimation Storyboard.TargetName="leftPupil" Storyboard.TargetProperty="Height" From="1" To="7" Duration="0:0:0.200" BeginTime="0:0:0.200" FillBehavior="HoldEnd"/> <DoubleAnimation Storyboard.TargetName="rightPupil" Storyboard.TargetProperty="Height" From="7" To="1" Duration="0:0:0.200" BeginTime="0:0:2.000" FillBehavior="HoldEnd"/> <DoubleAnimation Storyboard.TargetName="rightPupil" Storyboard.TargetProperty="Height" From="1" To="7" Duration="0:0:0.200" BeginTime="0:0:2.200" FillBehavior="HoldEnd"/> </Storyboard> <Storyboard x:Name="sbDie" x:Key="sbDie" Duration="0:0:3" FillBehavior="HoldEnd"> <DoubleAnimation Storyboard.TargetName="grdMain" Storyboard.TargetProperty="Opacity" From="0" To="1" RepeatBehavior="3" Duration="0:0:0.200" BeginTime="0:0:0.000" FillBehavior="HoldEnd"/> </Storyboard> <Storyboard x:Name="sbBorn" x:Key="sbBorn" Duration="0:0:3" FillBehavior="HoldEnd"> <DoubleAnimation Storyboard.TargetName="grdMain" Storyboard.TargetProperty="Opacity" From="1" To="1" RepeatBehavior="3" Duration="0:0:0.200" BeginTime="0:0:0.000" FillBehavior="HoldEnd"/> </Storyboard>
public void Die(AnimationCompleted endAnimationCallback) { rotateEyeBrow1.Angle = -15; rotateEyeBrow2.Angle = 15; pthTeeth.Visibility = pthMouth.Visibility = System.Windows.Visibility.Hidden; pthMouth2.Visibility = System.Windows.Visibility.Visible; Storyboard sbDie = this.FindResource("sbDie") as Storyboard; sbDie.Completed += (s, e) => { if (endAnimationCallback != null) endAnimationCallback(); }; IsDying = true; sbDie.Begin(); }
蜗牛根据玩家的指令移动(稍后将在文章中介绍的箭头键)。每一次移动都是通过垂直或水平动画(取决于移动方向)完成的,并且蜗牛一次只能移动一个单元格。只有当预期的新位置在迷宫边界内并且不与迷宫墙壁碰撞时,移动才会开始。
private void ProcessNextAnimation(Queue<Point> queue, Storyboard sb) { if (queue.Count > 0) { var deltaPoint = queue.Dequeue(); AnimateTopLeft(deltaPoint, this, sb); } } private void AnimateTopLeft(Point deltaPoint, FrameworkElement animal, Storyboard sb) { if (deltaPoint.X > 0) SnailDirection = Controls.SnailDirection.Right; else if (deltaPoint.X < 0) SnailDirection = Controls.SnailDirection.Left; if (deltaPoint.Y > 0) SnailDirection = Controls.SnailDirection.Down; else if (deltaPoint.Y < 0) SnailDirection = Controls.SnailDirection.Up; var left1 = (double)animal.GetValue(Canvas.LeftProperty); var top1 = (double)animal.GetValue(Canvas.TopProperty); var left2 = left1 + deltaPoint.X * cellWidth; var top2 = top1 + deltaPoint.Y * cellWidth; var newX = (int)(left2) / cellWidth; var newY = (int)(top2) / cellWidth; var ms = animationMs; var badMove = false; if (left2 < 0 || left2 > mazeWidth * cellWidth || top2 < 0 || top2 > mazeHeight * cellWidth) { left2 = left1; top2 = top1; ms = 100; badMove = true; } else if (MazeValues[newX, newY] == '1') { left2 = left1; top2 = top1; ms = 100; badMove = true; } var leftAnimation = new DoubleAnimation() { From = left1, To = left2, Duration = TimeSpan.FromMilliseconds(ms), }; var topAnimation = new DoubleAnimation() { From = top1, To = top2, Duration = TimeSpan.FromMilliseconds(ms), }; Storyboard.SetTarget(leftAnimation, animal); Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)")); Storyboard.SetTarget(topAnimation, animal); Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)")); sb.Children.Add(leftAnimation); sb.Children.Add(topAnimation); sb.Begin(); }
鱿鱼角色
也许这是最像《吃豆人》的部分:就像经典游戏中的幽灵一样,鱿鱼是坏蛋(至少在这个游戏里,对真正的鱿鱼没有冒犯),它们的作用是追逐我们的英雄无论他走到哪里。
与我们的蜗牛英雄不同,鱿鱼有固定的眼睛和冷酷的行为(就像达斯·维达和杰森·沃赫斯这样的反派),唯一适用于它们的动画是眼睛会随着它们的行走方向而变化(就像《吃豆人》中的幽灵一样)以及触手的移动。
对于触手的移动,我没有使用传统的WPF动画。相反,我通过及时地在两种可能的触手配置之间切换,并为鱿鱼的`Path`应用不同的绘图数据。
#region events void timer_Tick(object sender, EventArgs e) { if (feetState == -1) { pthBottom.Data = PathGeometry.Parse(@" M0,0 C00,0 05,20 10,10 C10,10 15,0 20,10 C20,10 25,20 30,10 C30,10 35,0 40,10 C40,10 45,20 50,0"); } else { pthBottom.Data = PathGeometry.Parse(@" M0,0 C00,00 05,20 10,10 C10,10 15,00 20,10 C20,10 25,20 30,10 C30,10 35,00 40,10 C40,10 45,20 50,10 C50,10 55,00 60,10 C60,10 65,20 70,0"); } feetState *= -1; } #endregion events
游戏控制键
键盘上的箭头键用于移动蜗牛,而空格键用于开始游戏并投掷海星(这听起来有点奇怪,但我稍后会解释)。
每个按下的箭头键都会生成一个不同的DeltaPoint [deltaX, deltaY],它对应于以单元格坐标为单位的移动。
private void Window_KeyDown(object sender, KeyEventArgs e) { var deltaX = 0; var deltaY = 0; var spacePressed = false; switch (e.Key) { case Key.Right: deltaX = 1; break; case Key.Left: deltaX = -1; break; case Key.Up: deltaY = -1; break; case Key.Down: deltaY = 1; break; case Key.Space: spacePressed = true; break; }
在游戏开始时按下空格键,游戏就会启动。
if (spacePressed) { if (splashScreen.Opacity == 1.0) { levelScreen.LevelNumber = level; Storyboard sbStart = this.FindResource("sbStart") as Storyboard; sbStart.Begin(); Storyboard sbLevel = this.FindResource("sbLevel") as Storyboard; sbLevel.Begin(); midiHelper.StopAll(); midiHelper.Play("km_start", () => { movementHalted = false; PlayStage1Music(); }); } else {
收集海星
如前所述,蜗牛可以将海星用作手里剑(忍者镖)来杀死靠近的鱿鱼。但这些海星只有在被拾取后才能作为武器使用。蜗牛可以收集尽可能多的海星。
要收集海星,蜗牛必须到达海星所在的单元格。当蜗牛矩形与海星矩形相交时,我们就知道海星已经被收集。
if (starfish.Visibility == System.Windows.Visibility.Visible) { var rectStarfish = starfish.GetRect(cnvMain); var starfishCellPoint = starfish.GetCellPoint(); if (rectSnail.IntersectsWith(rectStarfish)) { irrKlangEngine.Play2D(@"Sounds\Reload.wav"); gotStarfish = true; starfish.Visibility = System.Windows.Visibility.Hidden; AddStarfish(starfish); mazeValues[(int)starfishCellPoint.X, (int)starfishCellPoint.Y] = ' '; break; }
此外,还有控制海星数量并在得分面板上显示剩余数量的函数。
private void AddStarfish(Starfish starfish) { cnvMain.Children.Remove(starfish); collectedStarfishes.Push(starfish); txtStarfishes2.Text = string.Format("x{0}", collectedStarfishes.Count()); } private Starfish RemoveStarfish() { var starfish = collectedStarfishes.Pop(); txtStarfishes2.Text = string.Format("x{0}", collectedStarfishes.Count()); cnvMain.Children.Add(starfish); return starfish; }
投掷海星
在游戏过程中,按下空格键投掷海星(实际上,我们的蜗牛拥有忍者技能,可以让他像致命的手里剑一样处理海星)。投掷的海星会沿着蜗牛所指的方向移动。
if (collectedStarfishes.Count() > 0) { var starfishPoint1 = snail.GetCellPoint(); var starfish = RemoveStarfish(); var xDirection = 0; var yDirection = 0; switch (snail.SnailDirection) { case SnailDirection.Right: xDirection = 1; yDirection = 0; break; case SnailDirection.Left: xDirection = -1; yDirection = 0; break; case SnailDirection.Down: xDirection = 0; yDirection = 1; break; case SnailDirection.Up: xDirection = 0; yDirection = -1; break; }
一旦投掷,海星可以从蜗牛那里移动最多3个单元格。但与其他游戏中的事物一样,海星的移动必须遵守迷宫边界。我们可以通过一步一步地检查是否有边界或墙壁阻碍海星的路径来确保这些限制。
var targetX = (int)starfishPoint1.X; var targetY = (int)starfishPoint1.Y; var starfishPoint2 = new Point(targetX, targetY); var length = 0; for (var i = 1; i <= 3; i++) { targetX = (int)starfishPoint1.X + i * xDirection; targetY = (int)starfishPoint1.Y + i * yDirection; if (targetX >= 0 & targetX < mazeWidth & targetY >= 0 & targetY < mazeHeight) { if (mazeValues[targetX, targetY] == '1') { break; } else { starfishPoint2 = new Point(targetX, targetY); length = i; } } }
当海星被投掷时,会发出有趣的飞镖声。我们使用了`IrrKlang`框架(稍后会详细讨论)。
经过一些计算,我们定义了海星的起始点和终点,并且`Starfish`类中的**Throw**方法执行动画。
irrKlangEngine.Play2D(@"Sounds\boomerang.wav"); starfish.Throw(starfishPoint1, starfishPoint2, TimeSpan.FromMilliseconds((animationMs / 3.0) * length), () => { var starfishPoint = starfish.GetCellPoint(); foreach (var squid in squids) { var squidPoint = squid.GetCellPoint(); var x1 = (starfishPoint1.X < starfishPoint2.X) ? starfishPoint1.X : starfishPoint2.X; var x2 = (starfishPoint1.X < starfishPoint2.X) ? starfishPoint2.X : starfishPoint1.X; var y1 = (starfishPoint1.Y < starfishPoint2.Y) ? starfishPoint1.Y : starfishPoint2.Y; var y2 = (starfishPoint1.Y < starfishPoint2.Y) ? starfishPoint2.Y : starfishPoint1.Y; if ((x1 <= squidPoint.X & squidPoint.X <= x2 & squidPoint.Y == starfishPoint.Y) || (y1 <= squidPoint.Y & squidPoint.Y <= y2 & squidPoint.X == starfishPoint.X)) { irrKlangEngine.Play2D(@"Sounds\bulle.wav", false); squid.Die(() => { squid.IsDying = true; squid.Born(null); } ); break; } } }); } } } else { if (!movementHalted) { snail.TryMoveXY(new Point(deltaX, deltaY)); } } }
另一方面,`Throw`方法设置了必要的动画,使海星根据提供的参数旋转和飞行。
public void Throw(Point fromCellPoint, Point toCellPoint, TimeSpan duration, AnimationCompleted animationCompleted) { this.Visibility = System.Windows.Visibility.Visible; var leftAnimation = new DoubleAnimation() { From = fromCellPoint.X * cellWidth + 15, To = toCellPoint.X * cellWidth + 15, Duration = duration, }; var topAnimation = new DoubleAnimation() { From = fromCellPoint.Y * cellWidth + 15, To = toCellPoint.Y * cellWidth + 15, Duration = duration, }; Storyboard.SetTarget(leftAnimation, this); Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)")); Storyboard.SetTarget(topAnimation, this); Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)")); var sb = new Storyboard(); sb.Children.Add(leftAnimation); sb.Children.Add(topAnimation); sb.Completed += (s, e) => { sbRotate.Stop(); IsMoving = false; if (animationCompleted != null) animationCompleted(); }; IsMoving = true; sbRotate.Begin(); sb.Begin(); }
杀死鱿鱼
电子游戏的一个好处是可以毫不犹豫地杀戮。鱿鱼也在试图杀死你,所以尽可能多地收集海星并射击。气泡声表示鱿鱼已被杀死。
由于涉及到WPF动画,我们必须计算海星的起始点和终点,并找出这段空间中是否有海星。在这种情况下,找到的鱿鱼会被杀死。
foreach (var starfish in starfishes) { if (starfish.IsMoving) { var starfishPoint = starfish.GetCellPoint(); var x1 = (starfishPoint.X < snailPoint.X) ? starfishPoint.X : snailPoint.X; var x2 = (starfishPoint.X > snailPoint.X) ? starfishPoint.X : snailPoint.X; var y1 = (starfishPoint.Y < snailPoint.Y) ? starfishPoint.Y : snailPoint.Y; var y2 = (starfishPoint.Y > snailPoint.Y) ? starfishPoint.Y : snailPoint.Y; if ((x1 <= squidPoint.X & squidPoint.X <= x2 & starfishPoint.Y == squidPoint.Y) || (y1 <= squidPoint.Y & squidPoint.Y <= y2 & starfishPoint.X == squidPoint.X)) { AddScore(10); irrKlangEngine.Play2D(@"Sounds\bulle.wav", false); squid.Die(() => { squid.Born(() => { squid.ResetAnimations(); squid.IsDying = false; }); } ); break; } } }
被鱿鱼追赶(使用A*搜索算法)
我花了很多时间来找出让鱿鱼追逐蜗牛所需的算法。大多数时候,鱿鱼会卡住,或者最多只是四处闲逛,显得无聊且对蜗牛不感兴趣。
但后来我记起了我们的朋友Sacha Barber曾经发表过一篇关于A*搜索算法的精彩文章,其中涉及查找伦敦地铁任何两个站点之间的最佳路径。
我开始思考这个想法是否可以用在这个游戏中,并尝试了一下。令我惊讶的是,它效果惊人。我只需要改变概念:Sacha的文章讨论的是一个人试图找到当前站点和目标站点之间的最佳路径,并遵守站点之间的地理连接。另一方面,在Snail Quest中,人被鱿鱼代表。目标站点是蜗牛所在的单元格,站点是迷宫内的空单元格,而不是站点之间的连接,我们现在有了空单元格之间的连接,也就是迷宫内的“通道”。
public List<MovementType> DoSearch(Point squidPoint, Point snailPoint) { pathsSolutionsFound = new List<List<Point>>(); pathsAgenda = new List<List<Point>>(); List<Point> pathStart = new List<Point>(); pathStart.Add(squidPoint); pathsAgenda.Add(pathStart); while (pathsAgenda.Count() > 0) { List<Point> currPath = pathsAgenda[0]; pathsAgenda.RemoveAt(0); if (currPath.Count( x => x.Equals(snailPoint)) > 0) { pathsSolutionsFound.Add(currPath); break; } else { Point currPoint = currPath.Last(); List<Point> successorPoints = GetSuccessorsForPoint(currPoint); foreach (var successorPoint in successorPoints) { if (!currPath.Contains(successorPoint) & pathsSolutionsFound.Count(x => x.Contains(successorPoint)) == 0) { List<Point> newPath = new List<Point>(); foreach (var station in currPath) newPath.Add(station); newPath.Add(successorPoint); pathsAgenda.Add(newPath); //pathsAgenda.Sort(); } } } } //Finally, get the best Path, this should be the 1st one found due //to the heuristic evaluation performed by the search if (pathsSolutionsFound.Count() > 0) { var solutionPath = pathsSolutionsFound[0]; var movementList = new List<MovementType>(); var point = solutionPath[0]; for (var i = 1; i < solutionPath.Count(); i++) { var movement = MovementType.None; if (solutionPath[i].X > point.X) movement = MovementType.Right; if (solutionPath[i].X < point.X) movement = MovementType.Left; if (solutionPath[i].Y > point.Y) movement = MovementType.Bottom; if (solutionPath[i].Y < point.Y) movement = MovementType.Top; movementList.Add(movement); point = solutionPath[i]; } return movementList; } return null; }
一个需要注意的点:鱿鱼并不是一直追逐蜗牛。它们只有在无事可做时才会去追逐。但一旦它们开始追逐蜗牛,它们就会走完路径上的所有位置,最终找到蜗牛并根据更新后的位置进行追逐。
这种行为看起来有点愚蠢,但我这样做的目的是为了让游戏更容易完成。我相信有更好的方法可以做到这一点,我以后可能会改变这种行为。
下面的代码显示了鱿鱼何时应该寻找新路径以及何时不应该。这样做的方法是从一个名为`squidAnimationQueue`的变量中入队/出队,该变量保存了解决方案路径所需的所有移动(即,将鱿鱼引导到蜗牛当前位置的路径)。
public void ChaseSnail(Point snailPoint) { var squid = this; var xSquid = (int)((double)squid.GetValue(Canvas.LeftProperty) / cellWidth); var ySquid = (int)((double)squid.GetValue(Canvas.TopProperty) / cellWidth); var squidPoint = new Point(xSquid, ySquid); if (squidAnimationQueue.Count() == 0) { var solutionPath = DoSearch(squidPoint, snailPoint); if (solutionPath != null) { foreach (var movement in solutionPath) { var deltaX = 0; var deltaY = 0; switch (movement) { case MovementType.Right: deltaX = 1; break; case MovementType.Left: deltaX = -1; break; case MovementType.Bottom: deltaY = 1; break; case MovementType.Top: deltaY = -1; break; } squidAnimationQueue.Enqueue(new Point(deltaX, deltaY)); } } } MovementHalted = false; }
被鱿鱼杀死
当蜗牛被杀死时,会采取一些行动。首先,蜗牛动画会改变,使其面部看起来很惊恐。然后整个身体开始闪烁。最后,它会再次出现(前提是至少还有一条命),回到原来的位置。
private void RemoveLive() { if (lives == 0) { snail.Die(ResetPositions); snail.ResetAnimations(); gameOverScreen.Text = "Game Over"; Storyboard sbGameOver = this.FindResource("sbGameOver") as Storyboard; sbGameOver.Begin(); midiHelper.Play("km_gameover", () => { this.Dispatcher.Invoke((Action)delegate { LoadMaze(level); AddLive(); AddLive(); AddLive(); Storyboard sbSplashScreen = this.FindResource("sbSplashScreen") as Storyboard; sbSplashScreen.Begin(); }); }); } else { lives--; txtLives2.Text = string.Format("x{0}", lives); snail.Die(ResetPositions); snail.ResetAnimations(); var osWaitBeforeReborn = Observable.Interval(TimeSpan.FromMilliseconds(3000)).Take(1); osWaitBeforeReborn.Subscribe(e => { PlayStage1Music(); movementHalted = false; } ); } }
收集珍珠
要收集珍珠,蜗牛必须到达珍珠的位置。我们通过检测蜗牛的矩形是否与珍珠的矩形相交来测试这种碰撞。
foreach (var pearl in pearls) { if (pearl.Visibility == System.Windows.Visibility.Visible) { var rectPearl = pearl.GetRect(cnvMain); var pearlCellPoint = pearl.GetCellPoint(); if (rectSnail.IntersectsWith(rectPearl)) { AddScore(100); midiHelper.Play("km_crystal", null); AddPearl(pearl); pearl.Visibility = System.Windows.Visibility.Hidden; mazeValues[(int)pearlCellPoint.X, (int)pearlCellPoint.Y] = ' '; break; } } }
游戏得分
每次杀死鱿鱼、收集珍珠或完成关卡时,屏幕上的分数都会增加。这是通过一个简单的函数完成的。
private void AddScore(int points) { score += points; txtScore1.Text = txtScore2.Text = score.ToString("00000"); }
进入下一关
当给定关卡的所有珍珠都被收集后,游戏将进入下一关。每个关卡都有自己的.txt文件,如果该文件不存在,游戏将以不同的音乐结束。
请注意,`midiHelper.Play`方法接收一个匿名方法,该方法仅在整个音乐播放完毕后执行。
private void GoNextLevel() { movementHalted = true; midiHelper.StopAll(); var fileName = string.Format(@"Mazes\Level{0}.txt", level + 1); if (!File.Exists(fileName)) { midiHelper.StopAll(); gameOverScreen.Text = "Congratulations!"; Storyboard sbGameOver = this.FindResource("sbGameOver") as Storyboard; sbGameOver.Begin(); midiHelper.Play("km_ending", () => { this.Dispatcher.Invoke((Action)delegate { Storyboard sbSplashScreen = this.FindResource("sbSplashScreen") as Storyboard; sbSplashScreen.Begin(); }); }); } else { irrKlangEngine.Play2D(@"Sounds\bjp.wav", false); AddScore(200); var osWaitBeforeNextLevel = Observable.Interval(TimeSpan.FromMilliseconds(3000)).Take(1); osWaitBeforeNextLevel.Subscribe(e => { this.Dispatcher.Invoke((Action)delegate { level++; LoadMaze(level); ResetPositions(); levelScreen.LevelNumber = level; Storyboard sbLevel = this.FindResource("sbLevel") as Storyboard; sbLevel.Begin(); midiHelper.Play("km_start", () => { movementHalted = false; PlayStage2Music(); }); }); }); } }
使用C# Midi Toolkit播放游戏音乐
Leslie Sanford的C# Midi Toolkit是The Code Project上一个出色的文章贡献。如果您有时间深入研究他的代码,您会发现Leslie的工作非常出色。我在Snail Quest解决方案中将其作为编译好的dll使用,因此如果您对C# Midi Toolkit代码感兴趣,请从Leslie的文章中下载。
正如您所见,游戏在某些情况下使用midi音乐:游戏开场、关卡开始时、游戏结束事件以及游戏结束时。
在某些情况下,我们需要使用重叠的midi执行。如果您使用的是C# Midi Toolkit,通常只有一个音序器。当您使用一个音序器播放midi文件时,您无法同时播放另一个midi。为了解决这个问题,我创建了一个`MidiHelper`,它可以包含一个`Sequencer`对象字典,这些对象又可以并发播放。
public class MidiHelper { private int outDeviceID = 0; private OutputDevice outDevice; private Dictionary<string, Sequence> dicSequence = new Dictionary<string,Sequence>(); private Dictionary<string, Sequencer> dicSequencer = new Dictionary<string,Sequencer>(); private Dictionary<string, NoArgDelegate> dicPlayingCompleteDelegate = new Dictionary<string, NoArgDelegate>(); private Dictionary<string, int> dicSequencerMessageCount = new Dictionary<string, int>(); private Dictionary<string, bool> dicSequencerInitialized = new Dictionary<string, bool>(); private bool playing = false; private bool closing = false; public delegate void NoArgDelegate(); NoArgDelegate loadCompleted; NoArgDelegate playingCompleted; #region ctor public MidiHelper() { if (outDevice == null) outDevice = new OutputDevice(outDeviceID); } #endregion ctor #region methods public void InitializeSequencer(string midiKey) { var sequence = dicSequence[midiKey]; var sequencer = dicSequencer[midiKey]; sequencer.Stop(); playing = false; sequence.Format = 1; sequencer.Position = 0; sequencer.Sequence = sequence; sequencer.ChannelMessagePlayed += new System.EventHandler<Sanford.Multimedia.Midi.ChannelMessageEventArgs>(this.HandleChannelMessagePlayed); sequencer.Stopped += new System.EventHandler<Sanford.Multimedia.Midi.StoppedEventArgs>(this.HandleStopped); sequencer.SysExMessagePlayed += new System.EventHandler<Sanford.Multimedia.Midi.SysExMessageEventArgs>(this.HandleSysExMessagePlayed); sequencer.Chased += new System.EventHandler<Sanford.Multimedia.Midi.ChasedEventArgs>(this.HandleChased); sequence.LoadCompleted += HandleLoadCompleted; } public void Load(string midiKey, string midiFile) { dicSequence.Add(midiKey, new Sequence()); dicSequencer.Add(midiKey, new Sequencer(midiKey)); dicPlayingCompleteDelegate.Add(midiKey, null); dicSequencerMessageCount.Add(midiKey, 0); dicSequencerInitialized.Add(midiKey, false); InitializeSequencer(midiKey); dicSequencer[midiKey].Stop(); dicSequencer[midiKey].ChannelMessagePlayed += (s, e) => { dicSequencerMessageCount[midiKey]++; }; dicSequencer[midiKey].PlayingCompleted += (s, e) => { if (dicSequencerMessageCount[midiKey] > 0) { var playingCompleted = dicPlayingCompleteDelegate[midiKey]; if (playingCompleted != null) playingCompleted(); dicSequencer[midiKey].Stop(); dicSequencerMessageCount[midiKey] = 0; } }; playing = false; dicSequence[midiKey].LoadAsync(midiFile); } public void Play(string midiKey, NoArgDelegate playingCompleted) { playing = true; dicPlayingCompleteDelegate[midiKey] = playingCompleted; if (!dicSequencerInitialized[midiKey]) { dicSequencerInitialized[midiKey] = true; dicSequencer[midiKey].GetTracks(); } dicSequencer[midiKey].Stop(); dicSequencer[midiKey].Start(); } public void Continue(string midiKey) { playing = true; dicSequencer[midiKey].Continue(); } public void Stop(string midiKey) { playing = false; dicSequencer[midiKey].Stop(); } public void StopAll() { foreach (var kv in dicSequencer) { kv.Value.Stop(); } } #endregion methods #region events private void HandleChannelMessagePlayed(object sender, ChannelMessageEventArgs e) { if (closing) { return; } outDevice.Send(e.Message); } private void HandleChased(object sender, ChasedEventArgs e) { foreach (ChannelMessage message in e.Messages) { outDevice.Send(message); } } private void HandleSysExMessagePlayed(object sender, SysExMessageEventArgs e) { outDevice.Send(e.Message); //Sometimes causes an exception to be thrown because the output device is overloaded. } private void HandleStopped(object sender, StoppedEventArgs e) { foreach (ChannelMessage message in e.Messages) { outDevice.Send(message); } } private void HandleLoadCompleted(object sender, AsyncCompletedEventArgs e) { if (loadCompleted != null) loadCompleted(); } #endregion events }
使用IrrKlang引擎播放音效
IrrKlang是一个很棒的跨平台音频库,非常易于使用,并且对非商业用途是免费的。对于商业应用,您应该购买许可证。
但幸运的是(对您来说),我不会通过Snail Quest赚钱,所以我可以与Code Project的读者分享。
您可能会想,为什么我还要费力使用两个声音引擎(C# Midi Toolkit和IrrKlang)在游戏中。当然,.mp3和.wav文件的质量比midi文件要好。但使用.mp3或.wav文件也有体积上的权衡,因为2分钟的音乐可能意味着几兆字节,而复杂的midi音乐可以用更少的空间存储。无论如何,我认为这是一个很好的编码练习,而且对于Code Project的读者来说也更方便,因为他们需要下载的文件更少。
它真的很容易使用。看看下面我需要多少行代码来播放一个声音。
IrrKlang.ISoundEngine irrKlangEngine; IrrKlang.ISound currentlyPlayingSound; ... irrKlangEngine = new IrrKlang.ISoundEngine(); ... irrKlangEngine.Play2D(@"Sounds\boomerang.wav");
最终考虑
就这样。我在编写代码和文章的过程中非常愉快,希望您喜欢它。如果您喜欢这篇文章,请在下方留下评论。如果您不喜欢,也请留下评论。您的反馈对我非常重要。
历史
- 2011-03-29:初始版本。
- 2011-04-05:小幅修正。