欢迎访问 生活随笔!

生活随笔

当前位置: 首页 >

Win Form图形编程实践——打砖块

发布时间:2023/12/20 46 豆豆
生活随笔 收集整理的这篇文章主要介绍了 Win Form图形编程实践——打砖块 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

Win Form图形编程实践——打砖块

引言

本项目学习自际为软件事务所的C#实现一个打砖块游戏 Step By Step,特此鸣谢。

最初,博主只是在简单学习了Win Form之后写一个有图形化界面的小游戏来锻炼一下自己的图形化编程技巧,思前想后选择了打砖块这个游戏。相较于贪吃蛇一类的小游戏,打砖块还是很有难度的,因为要控制球的运动轨迹以及球和砖块、挡板的碰撞问题,设计起来较为繁琐。幸好有上文提到的教程,在OOP方面提供了一个很好的思路,再次感谢。

另:为避免版权问题,游戏中所有涉及到的图片及图标等均为个人绘制。

思路

在寻找教程之前我自己写了一个导图来整理这个游戏的大体思路。如下:

整合网络教程以及我个人的思路,实现这个打砖块游戏的具体过程如下:

  • 设计开始界面,具体如导图所示
    • 标题
    • 操作提示
    • [开始游戏]按钮
    • 开始界面背景图
  • 设计游戏界面,主要参照教程进行:
    • 游戏界面背景图
    • 绘制挡板以及实现挡板的移动
    • 绘制小球以及实现小球的移动
    • 绘制砖块以及实现墙体集合
    • 重新使用双缓冲技术实现绘图操作
    • 实现小球与砖块和挡板的碰撞检测
  • 设计展示界面,具体如导图所示:
    • 计分板
    • 游戏进行时间
    • 排行榜
  • 设计游戏结束界面,具体如导图所示:
    • 标题
    • 判断分数
      • 如果进入前三名:记录玩家姓名并更新排行榜后展示[结束游戏]按钮
      • 如果没进入前三名:直接展示[结束游戏]按钮
    • [结束游戏]按钮

实现过程

开始界面

  • 设置窗口格式

    将AutoSize设置为False,Size为450x620.

    设置窗口图标。

    将Locked设置为True。

    设置窗口内默认字体,这样在添加Label或者TextBox时就不用重新设置字体了,但有可能仍需要重新调整字体大小。

  • 设置背景图片

    将图片文件夹放置在所建项目文件夹中的bin\Debug中便于检索文件。

    将这个操作放置在[Form]BricksBreaker的Load事件中,具体代码如下:

    private void BricksBreaker_Load(object sender, EventArgs e){Welcome();string backgroundImagePath = Application.StartupPath;backgroundImagePath += @"\imgs\Welcome\BackGround.png";this.BackgroundImage = Image.FromFile(backgroundImagePath);this.BackgroundImageLayout = ImageLayout.Stretch;}

    Systems.Windows.Forms.Application.StartupPath是运行时.exe文件的位置,采用相对路径可以方便后续实现安装的操作。

  • Welcome()函数

    最开始是为了便于操作主界面以及实现[再来一局]按钮创造的函数,将各个组件的初始化放在了这个函数中,但是进行到左后发现其实并不能简化而且难以在复杂的Welcome()函数中分离出适合于第二局游戏的语句,便搁置在此。

    private void Welcome(){GameStart.Visible = true;GameTitle.Visible = true;Hint.Visible = true;GameBox.Visible = false;Suggestion.Visible = false;ScoreBoard.Visible = false;ScoreRec.Visible = false;PlayersTitle.Visible = false;BestPlayers.Visible = false;GameTime.Visible = false;GameOverTitle.Visible = false;NameRecTitle.Visible = false;NameRec.Visible = false;NameConfirm.Visible = false;Close.Visible = false;}

    [Label]GameStart为[开始游戏]按钮,[Label]GameTitle为标题,[Label]Hint为操作提示。

    其他为后续实现过程中逐渐添加的语句,故不在此叙述。

  • [Label]GameStart的Click事件

    private void GameStart_Click(object sender, EventArgs e){GameStartFunc();this.GameBox.Refresh();}private void GameStartFunc(){GameStart.Visible = false;GameTitle.Visible = false;Hint.Visible = false;string gamePageImagePath = Application.StartupPath;gamePageImagePath += @"\imgs\GamePage\GamePage.png";GameBox.BackgroundImage = Image.FromFile(gamePageImagePath);GameBox.BackgroundImageLayout = ImageLayout.Stretch;GameBox.Visible = true;Suggestion.Visible = true;ScoreBoard.Visible = true;ScoreRec.Visible = true;PlayersTitle.Visible = true;BestPlayers.Visible = true;GameTime.Visible = true;objectList.Add(board);objectList.Add(ball);objectList.Add(bricks);string nameListPath = Application.StartupPath;nameListPath += @"\data\PlayersNameList.txt";System.IO.StreamReader streamReader = new System.IO.StreamReader(nameListPath);BestPlayers.Text = streamReader.ReadToEnd();streamReader.Close();players.Add(new Player(BestPlayers.Lines[0]));players.Add(new Player(BestPlayers.Lines[1]));players.Add(new Player(BestPlayers.Lines[2]));}

    同样的GameStartFunc()也是为了方便[再来一局]按钮的事件而做出来的冗余函数(lll¬ω¬)。

    部分语句为后续添加。

  • 游戏界面

  • 建立[PictureBox]GameBox

    [PictureBox]GameBox的相关属性:

    Dock: True

    Size: 432x573

    Locked: True

    GameStartFunc()内相关初始化语句为:

    string gamePageImagePath = Application.StartupPath; gamePageImagePath += @"\imgs\GamePage\GamePage.png"; GameBox.BackgroundImage = Image.FromFile(gamePageImagePath); GameBox.BackgroundImageLayout = ImageLayout.Stretch; GameBox.Visible = true;

    选择PictureBox作为图形的载体,在[PictureBox]GameBox的Paint事件中置入各个组件的绘制语句。

    private void GameBox_Paint(object sender, PaintEventArgs e){Bitmap bitmap = new Bitmap(GameBox.Width, GameBox.Height);foreach(Object @object in objectList){// @object.Draw(e.Graphics, this.GameBox);@object.Draw(Graphics.FromImage(bitmap), this.GameBox);}e.Graphics.DrawImage(bitmap, 0, 0);}

    其中[List]objectList为各个Object的集合,通过foreach简化代码。

    重绘[PictureBox]GameBox可以用GameBox.Refresh()实现。

    (注释部分为直接绘制,未注释部分为双缓冲操作)

    添加一个计时器[Timer]Action,利用其Tick事件来刷新图形以及控制刷新频率:

    private void Action_Tick(object sender, EventArgs e){ball.Run(GameBox, board, bricks, ref score);if (ball.touchedBound){Action.Stop();GameOver();}GameBox.Refresh();}

    同时Tick事件中还可以添加其他不同功能的语句,将会在后续过程中解释。

  • 在[PictureBox]GameBox中绘制图形

  • Object类

    在编写Board类和Ball类后将相似代码提取出来构造一个基类Object类。

    class Object{public int xPos { get; set; }public int yPos { get; set; }public Rectangle rectangle { get; set; }public bool isDelete = false;public virtual void Draw(Graphics g, PictureBox GameBox) { }}

    xPos, yPos为Ball类和Board类的相似变量,使用方法也大致相同。

    另附:C#值Get、Set用法(作者:雨夜潇潇)

    [Rectangle]rectangle为绘制图形时所需的变量

    为Object类构建一个Draw虚方法之后可以在[PictureBox]GameBox的Paint事件中达到简化代码的作用,即不必每个组件依次使用一条语句来调用各自类中不同的Draw()方法。

  • Board类

    Board类有几个基本参数:横坐标、纵坐标、宽度、高度以及移动速度。

    class Board : Object{public int boardWidth, boardHeight;public int speedX { get; set; }public const double collsion_MaxAngleIncrement = 0.25;public enum BoardDirection{Left, Right, None}public Board(int x, int y, int width, int height, int speedx=8){this.xPos = x;this.yPos = y;this.speedX = speedx;boardWidth = width;boardHeight = height;}public override void Draw(Graphics g, PictureBox GameBox){SolidBrush solidBrush = new SolidBrush(Color.BurlyWood);Pen pen = new Pen(Color.SaddleBrown, 2);rectangle = new Rectangle(GameBox.Left + xPos, GameBox.Top + yPos, boardWidth, boardHeight);g.DrawRectangle(pen, rectangle);g.FillRectangle(solidBrush, rectangle);}public void Move(BoardDirection direction, PictureBox GameBox){switch (direction){case BoardDirection.Left:if(xPos - speedX > BricksBreaker.BorderWidth){xPos -= speedX;}else{xPos = BricksBreaker.BorderWidth;}break;case BoardDirection.Right:if(xPos + boardWidth + BricksBreaker.BorderWidth + speedX < GameBox.Width){xPos += speedX;}else{xPos = GameBox.Width - boardWidth - BricksBreaker.BorderWidth;}break;}}}

    其中的Move()方法利用Board类中的枚举变量确定挡板的移动方向。BricksBreaker.BorderWidth为[Form]BricksBreaker中定义的常量,表示游戏界面中边框的宽度。

    Move()方法的调用在[Form]BricksBreaker的KeyDown事件中:

    private void BricksBreaker_KeyDown(object sender, KeyEventArgs e){if (GameBox.Visible){switch (e.KeyData){case Keys.A:if (gameStarted){board.Move(Board.BoardDirection.Left, GameBox);}break;case Keys.D:if (gameStarted){board.Move(Board.BoardDirection.Right, GameBox);}break;case Keys.Space:if (!gameStarted){gameStarted = true;Suggestion.Visible = false;Action.Interval = 100;Action.Start();}break;default:break;}// GameBox.Refresh();}}

    同时这个KeyDown事件中还实现了[开始游戏]按钮的后续——按空格键发射小球,使用gameStarted判断游戏是否已经正式开始。

    起初挡板的绘制语句是在KeyDown事件中的,在挡板坐标改变后直接在[PictureBox]GameBox中绘制挡板,在实现双缓冲时将语句移动到其Load事件中。

  • Ball类

    Ball类的参数与Board类相似但略有不同:横坐标、纵坐标、半径、速度方向以及速度大小。

    采用向量而不是坐标增量的方式表示速度有利于控制小球的方向。因为在此之前有设想过挡板的不同位置对小球的速度影响不同,即距离挡板中心越远速度偏离越大,若是使用坐标增量的方式表示速度可能计算比较复杂或者不好控制之类的,而使用向量的方式表示速度则可以直接在代表速度方向的变量上加减。

    class Ball : Object{public const int radius = 8;public double speedAngle { get; set; }public int speedDis { get; set; }public bool touchedBound = false;private const int speedIncrement = 1;public Ball(int x, int y, double angle=0.5, int dis = 5){this.xPos = x;this.yPos = y;this.speedAngle = angle;this.speedDis = dis;}public void SpeedUpdate(){this.speedDis += speedIncrement;}public override void Draw(Graphics g, PictureBox GameBox){SolidBrush solidBrush = new SolidBrush(Color.LightGoldenrodYellow);Pen pen = new Pen(Color.SaddleBrown, 2);rectangle = new Rectangle(GameBox.Left + xPos - radius, GameBox.Top + yPos - radius, 2 * radius, 2 * radius);g.DrawEllipse(pen, rectangle);g.FillEllipse(solidBrush, rectangle);}public void Run(PictureBox GameBox, Board board, Bricks bricks, ref int score){int xDis = (int)(speedDis * Math.Cos(Math.PI * speedAngle));int yDis = -(int)(speedDis * Math.Sin(Math.PI * speedAngle));xPos += xDis;yPos += yDis;// Hit(board, bricks, ref score);if (xPos + radius + BricksBreaker.BorderWidth >= GameBox.Width){xPos = GameBox.Width - BricksBreaker.BorderWidth - radius;speedAngle = 1.0 - speedAngle;}if(xPos - radius <= BricksBreaker.BorderWidth){xPos = BricksBreaker.BorderWidth + radius;speedAngle = 1.0 - speedAngle;}if(yPos + radius >= board.yPos + board.boardHeight){yPos = board.yPos + board.boardHeight - radius;speedAngle = -speedAngle;this.touchedBound = true;}if(yPos - radius <= BricksBreaker.BorderWidth){yPos = BricksBreaker.BorderWidth + radius;speedAngle = -speedAngle;}}}

    其中Ball类的Draw()方法与Board类类似,只是颜色有所改变。

    SpeedUpdate()方法是为后续设计关卡准备的。

    Run()方法为小球的基本移动方法,具体有坐标移动和检测与边界的碰撞。

    在后面会实现Ball类与挡板以及砖块的碰撞检测方法。

  • Brick类/Bricks类

    Brick类与Board类基本一致。

    class Brick : Object{public const int brickWidth = 67, brickHeight = 20;public const int score = 5;public Brick() { }public Brick(int x, int y){xPos = x;yPos = y;}public override void Draw(Graphics g, PictureBox GameBox){SolidBrush solidBrush = new SolidBrush(Color.FromArgb(181, 99, 0));Pen pen = new Pen(Color.SaddleBrown, 4);rectangle = new Rectangle(GameBox.Left + xPos, GameBox.Top + yPos, brickWidth, brickHeight);g.DrawRectangle(pen, rectangle);g.FillRectangle(solidBrush, rectangle);}}

    对砖块的集合单独创建一个Bricks类,以便在[List]objectList中调用Draw()方法。

    初始化函数用来创建一个砖墙。

    Draw()方法中分别调用每一个砖块的Draw()方法。

    class Bricks : Object{private const int _width = 402+BricksBreaker.BorderWidth, _height = 140+BricksBreaker.BorderWidth;public const int width = 6, height = 7;public List<Brick> bricks { get; set; }public Bricks(){MakeBrickWall();}private void MakeBrickWall(){bricks = new List<Brick>();for (int y = 15; y < _height; y += Brick.brickHeight) {for (int x = 15;x < _width;x += Brick.brickWidth){Brick brick = new Brick(x, y);bricks.Add(brick);}}}public override void Draw(Graphics g, PictureBox GameBox){foreach(Brick brick in bricks){if(brick.isDelete){continue;}brick.Draw(g, GameBox);}}}
  • 双缓冲技术

    双缓冲技术:

    即在内存中创建一个与屏幕绘图区域一致的对象,先将图形绘制到内存中的这个对象上,再一次性将这个对象上的图形拷贝到屏幕上,这样能大大加快绘图的速度。

    双缓冲技术在C#中的实现过程:

    先创建一个Bitmap实例bitmap,大小与[PictureBox]GameBox相同。

    Bitmap bitmap = new Bitmap(GameBox.Width, GameBox.Height);

    将本来要绘制在[PictureBox]GameBox中的图形绘制到[Bitmap]bitmap中。

    foreach(Object @object in objectList){// @object.Draw(e.Graphics, this.GameBox);@object.Draw(Graphics.FromImage(bitmap), this.GameBox);}

    用DrawImage将[Bitmap]bitmap绘制到[PictureBox]GameBox中。

    整体代码如下:

    private void GameBox_Paint(object sender, PaintEventArgs e){Bitmap bitmap = new Bitmap(GameBox.Width, GameBox.Height);foreach(Object @object in objectList){@object.Draw(Graphics.FromImage(bitmap), this.GameBox);}e.Graphics.DrawImage(bitmap, 0, 0);}
  • Ball类的碰撞检测

    Ball类的碰撞检测分为两个部分,一是和挡板的碰撞,二是和砖块的碰撞。两种碰撞分别会产生不同的效果。

    对砖块的碰撞会造成砖块的删除和分数的增加,同时小球方向改变。

    对挡板的碰撞会使小球方向改变,且改变的多少随碰撞位置的变化而变化。

    具体代码如下:

    private void Hit(Board board, Bricks brickset, ref int score){for (int i = 0; i < brickset.bricks.Count; i++){if (brickset.bricks[i].isDelete){continue;}int leftBound = brickset.bricks[i].xPos;int rightBound = leftBound + Brick.brickWidth;int upBound = brickset.bricks[i].yPos;int downBound = upBound + Brick.brickHeight;if(xPos < leftBound && xPos + radius >= leftBound){if(yPos > upBound && yPos < downBound){xPos = leftBound - radius;speedAngle = 1.0 - speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;}if(yPos == downBound){xPos = leftBound - radius;speedAngle = 1.0 - speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;if (i + Bricks.width < brickset.bricks.Count && !brickset.bricks[i + Bricks.width].isDelete){brickset.bricks[i + Bricks.width].isDelete = true;score += Brick.score;}}}else if(xPos > rightBound && xPos - radius <= rightBound){if(yPos > upBound && yPos < downBound){xPos = rightBound + radius;speedAngle = 1.0 - speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;}if (yPos == downBound){xPos = rightBound + radius;speedAngle = 1.0 - speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;if (i + Bricks.width < brickset.bricks.Count && !brickset.bricks[i + Bricks.width].isDelete){brickset.bricks[i + Bricks.width].isDelete = true;score += Brick.score;}}}else if(yPos < upBound && yPos + radius >= upBound){if(xPos > leftBound && xPos < rightBound){yPos = upBound - radius;speedAngle = -speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;}if(xPos == rightBound){yPos = upBound - radius;speedAngle = -speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;if(i % Bricks.width < 5 && !brickset.bricks[i + 1].isDelete){brickset.bricks[i + 1].isDelete = true;score += Brick.score;}}}else if(yPos > downBound && yPos - radius <= downBound){if (xPos > leftBound && xPos < rightBound){yPos = downBound + radius;speedAngle = -speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;}if (xPos == rightBound){yPos = downBound + radius;speedAngle = -speedAngle;brickset.bricks[i].isDelete = true;score += Brick.score;if (i % Bricks.width < 5 && !brickset.bricks[i + 1].isDelete){brickset.bricks[i + 1].isDelete = true;score += Brick.score;}}}}if (yPos < board.yPos && yPos + radius >= board.yPos && xPos >= board.xPos && xPos <= board.xPos + board.boardWidth){yPos = board.yPos - radius - 1;speedAngle = -speedAngle;int dis = board.xPos + board.boardWidth / 2 - xPos;double angleIncrement = (double)dis / (double)board.boardWidth * Board.collsion_MaxAngleIncrement;speedAngle += angleIncrement;}}

    判断过程比较繁琐,与对边界的碰撞判断相同,为了防止出现图像穿模的情况使用类似:

    if(*** <= **){*** = **; }

    的方式强制使图形不会越界。

  • 展示界面

    展示界面分别使用[Label]ScoreRec, [Label]GameTime, [TextBox]BestPlayers来分别表示当前分数,游戏时间,排行榜。

    分数实时显示

    分数的实时显示在[Timer]Action的Tick事件中实现:

    timeScore = (timeScore + 1) % timeScoreLoop; if(timeScore == 0) {score++; }ScoreRec.Text = score.ToString("D5");

    其中score、timeScore和timeScoreLoop为[Form]BricksBreaker中定义的整型变量:

    int score = 0; private int timeScore = 0; private const int timeScoreLoop = 15;

    由于游戏规则设定为分数随游戏时间的增加和砖块数量的减少而增加,而每次如果调用Tick()事件时都使score增加会降低不少游戏难度,所以设置timeScoreLoop来控制score随游戏时间增加的速度。

    游戏时间展示

    游戏时间利用每次调用Tick事件时的绝对时间和游戏正式开始时的绝对时间做减法得到一个相对时间,最后将其转换为可以用来展示的格式。

    具体实现过程如下:

  • 在[Form]BricksBreaker中定义一个[DateTime]beginTime用来表示游戏正式开始时的绝对时间:

    DateTime beginTime = new DateTime();
  • 在KeyDown事件中,将[DateTime]beginTime设置为当前时间:

    case Keys.Space:if (!gameStarted){gameStarted = true;Suggestion.Visible = false;beginTime = System.DateTime.Now;Action.Interval = 100;Action.Start();}break;
  • 在Tick事件中,利用[DateTime]nowTime与[DateTime]beginTime的差值,与DateTime中的最小时间相加将[TimeSpan]gameTime转换为可以转换为表示时间的字符串的[DateTime]类型

    DateTime nowTime = System.DateTime.Now; TimeSpan gameTime = nowTime - beginTime; GameTime.Text = System.DateTime.MinValue.AddMilliseconds(gameTime.TotalMilliseconds).ToLongTimeString();
  • 排行榜界面

    在GameStartFunc函数中添加如下语句:

    string nameListPath = Application.StartupPath; nameListPath += @"\data\PlayersNameList.txt"; System.IO.StreamReader streamReader = new System.IO.StreamReader(nameListPath); BestPlayers.Text = streamReader.ReadToEnd(); streamReader.Close();

    其中nameListPath表示排行榜文件所在位置。

    结束界面

    当Tick事件中检测到[Ball]ball的touchedBound为True时,结束[Timer]Action的计时,并启动GameOver函数:

    private void GameOver(){GameOverTitle.Visible = true;}

    为了便于判断玩家是否进入排行榜,进行以下操作:

  • 创建一个Player类并重载它的ToString()方法:

    class Player{public string name;public int score;public Player(string line){string[] info = line.Split(' ');this.name = info[1];this.score = Convert.ToInt32(info[2]);}public Player(string nam, int scor){this.name = nam;this.score = scor;}public override string ToString(){return name + " " + score.ToString("D5");}}
  • 在[Form]BricksBreaker中定义一个[List]players:

    List<Player> players = new List<Player>();
  • 在GameStartFunc函数中添加如下语句进行初始化:

    players.Add(new Player(BestPlayers.Lines[0])); players.Add(new Player(BestPlayers.Lines[1])); players.Add(new Player(BestPlayers.Lines[2]));
  • 在[Form]BricksBreaker中定义一个变量playerRank用来表示玩家的排名:

    private int playerRank = -1;
  • 在GameOver函数中添加如下语句:

    int index = 0; for(;index < players.Count; index++) {if(score >= players[index].score){for(int i = players.Count - 1;i > index; i--){players[i] = players[i - 1];}playerRank = index;NameRecTitle.Visible = true;NameRec.Visible = true;NameConfirm.Visible = true;break;} } if(playerRank == -1) {GameOverButtons(); }
  • 如果玩家没有进入排行榜则直接展示[结束游戏]按钮。

    private void GameOverButtons(){NameRecTitle.Visible = false;NameRec.Visible = false;NameConfirm.Visible = false;Close.Visible = true;}

    [结束游戏]按钮(即[Button]Close)的Click事件:

    private void Close_Click(object sender, EventArgs e){this.Close();}

    如果玩家进入排行榜则展示[Label]NameRecTitle、[TextBox]NameRec和[Button]NameConfirm,进行玩家名称的输入。

    在[Button]NameConfirm的Click事件中添加如下语句:

    private void NameConfirm_Click(object sender, EventArgs e){string playerName = NameRec.Text;players[playerRank] = new Player(playerName, score);string[] lines = new string[players.Count];for(int i = 0;i < players.Count; i++){lines[i] = (i + 1).ToString() + ". " + players[i].ToString();}string nameListPath = Application.StartupPath;nameListPath += @"\data\PlayersNameList.txt";System.IO.File.WriteAllLines(nameListPath, lines);System.IO.StreamReader streamReader = new System.IO.StreamReader(nameListPath);BestPlayers.Text = streamReader.ReadToEnd();streamReader.Close();GameOverButtons();}

    将新输入的玩家名称保存到排行榜文件中同时更新排行榜界面。

    制作安装包

    利用Visual Studio 2019中的Microsoft Visual Studio Installer Projects拓展制作游戏安装包。

    首先,新建一个Setup Wizard项目,项目名称即为最后生成的安装包的名称。

    然后在Application Folder中添加所安装的应用程序以及相关文件,注意应和程序调用文件时使用的的相对路径保持一致。在添加应用程序文件之前可以使用Resource Hacker更换一下图标文件。

    最后在解决方案资源管理器中右键项目,然后选择“生成”,便大功告成了!

    Github

    Github代码地址

    总结

    以上是生活随笔为你收集整理的Win Form图形编程实践——打砖块的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。