C---游戏编程入门指南第二版-全-
C++ 游戏编程入门指南第二版(全)
原文:
zh.annas-archive.org/md5/999f82553110c5c2167046fb0272d333
译者:飞龙
第一章:贡献者
关于作者
约翰·霍顿是一位基于英国的编程和游戏爱好者。他对编写应用程序、游戏、书籍和博客文章充满热情。他是 Game Code School 的创始人。
关于审稿人
安德烈亚斯·奥赫尔克是一位专业的全栈软件开发工程师。他拥有计算机科学学士学位,喜欢尝试软件和硬件。他的标志一直是他对电子和计算机的热情和亲和力。他的爱好包括游戏开发、构建嵌入式系统、运动和制作音乐。他目前在一家德国金融机构全职担任高级软件工程师。此外,他还曾在加利福尼亚州旧金山担任顾问和游戏开发者。他还是《学习 LibGDX 游戏开发》一书的作者。
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
目录
前言
第一章:C++、SFML、Visual Studio 和开始第一个游戏
2 我们将构建的游戏
2 森林!!!
3 乒乓球
3 丧尸竞技场
4 托马斯迟到了
4 外星人入侵++
5 遇见 C++
6 微软 Visual Studio
6 SFML
7 设置开发环境
7 关于 Mac 和 Linux 怎么办?
8 安装 Visual Studio 2019 社区版
10 设置 SFML
12 创建新项目
16 配置项目属性
18 规划森林!!!
22 项目资源
22 外包资源
22 制作自己的音效
23 将资源添加到项目中
23 探索资源
24 理解屏幕和内部坐标
27 开始编写游戏代码
28 使用注释使代码更清晰
28 主函数
29 展示和语法
30 从函数返回值
31 运行游戏
31 使用 SFML 打开窗口
32 #包括 SFML 功能
33 面向对象编程、类和对象
35 使用命名空间 sf
35 SFML VideoMode 和 RenderWindow
36 运行游戏
36 主游戏循环
38 当循环
39 C 风格代码注释
39 输入、更新、绘制、重复
40 检测按键
40 清除和绘制场景
41 运行游戏
41 绘制游戏背景
41 使用纹理准备精灵
44 背景精灵的双缓冲
45 运行游戏
45 处理错误
46 配置错误
46 编译错误
46 链接错误
47 故障
47 摘要
47 常见问题解答
第二章:变量、运算符和决策 – 精灵动画
50 C++ 变量
50 变量的类型
51 声明和初始化变量
55 操作变量
55 C++ 算术和赋值运算符
使用表达式完成任务
添加云彩、一棵树和一只嗡嗡叫的蜜蜂
准备树
准备蜜蜂
准备云彩
绘制树、蜜蜂和云彩
随机数
在 C++ 中生成随机数
使用 if 和 else 做决策
逻辑运算符
C++ 中的 if 和 else
如果他们从桥上过来,就射击他们!
射击他们……或者做这个代替
读者挑战
计时
帧率问题
SFML 帧率解决方案
移动云彩和蜜蜂
赋予蜜蜂生命
吹动云彩
总结
常见问题解答
第三章: C++ 字符串和 SFML 时间 – 玩家输入和用户界面
暂停和重新开始游戏
C++ 字符串
声明字符串
将值赋给字符串
操作字符串
SFML 的文本和字体类
实现用户界面
添加时间条
总结
常见问题解答
第四章: 循环、数组、开关、枚举和函数 – 实现游戏机制
循环
while 循环
for 循环
数组
声明数组
初始化数组元素
这些数组对我们游戏真正做了什么?
使用 switch 进行决策
类枚举
函数入门
函数返回类型
函数名称
函数参数
函数体
函数原型
组织函数
函数陷阱!
更多关于函数的内容
关于函数的绝对最终话 - 至今
生长树枝
准备树枝
每帧更新分支精灵
绘制树枝
移动树枝
总结
常见问题解答
第五章:碰撞、声音和结束条件 – 使游戏可玩
准备玩家(和其他精灵)
绘制玩家和其他精灵
处理玩家的输入
处理设置新游戏
检测玩家切割
检测按键释放
动画切割的木材和斧头
处理死亡
简单的声音效果
SFML 声音如何工作
何时播放声音
添加声音代码
改进游戏和代码
总结
常见问题解答
第六章: 面向对象编程 - 开始 Pong 游戏
面向对象编程
封装
多态
继承
为什么使用面向对象编程?
究竟什么是类?
Pong 球拍的理论
类变量和函数声明
类函数定义
使用类的实例
创建 Pong 项目
编写 Bat 类
编写 Bat.h
构造函数
继续解释 Bat.h
编写 Bat.cpp
使用 Bat 类和编写主函数
总结
常见问题解答
第七章: 动态碰撞检测和物理 - 完成 Pong 游戏
编写 Ball 类
使用 Ball 类
碰撞检测和计分
运行游戏
总结
常见问题解答
第八章: SFML 视图 - 开始 Zombie Shooter 游戏
规划和启动 Zombie Arena 游戏
创建新项目
项目资源
探索资源
将资源添加到项目中
面向对象编程和 Zombie Arena 项目
创建玩家 - 第一个类
编写 Player 类头文件
229 编写玩家类函数定义
239 使用 SFML View 控制游戏摄像机
242 启动僵尸竞技场游戏引擎
247 管理代码文件
249 开始编写主游戏循环代码
260 摘要
260 常见问题解答
第九章: C++ 引用、精灵图集和顶点数组
262 C++ 引用
265 引用摘要
266 SFML 顶点数组和精灵图集
266 什么是精灵图集?
267 什么是顶点数组?
268 从瓦片构建背景
269 构建顶点数组
271 使用顶点数组进行绘制
271 创建随机生成的滚动背景
279 使用背景
282 摘要
283 常见问题解答
第十章: 指针、标准模板库和纹理管理
286 了解指针
287 指针语法
288 声明指针
289 初始化指针
290 重新初始化指针
291 解引用指针
293 指针是多才多艺且强大的
297 指针和数组
298 指针摘要
298 标准模板库
299 什么是映射?
300 声明映射
300 向映射中添加数据
301 在映射中查找数据
301 从映射中删除数据
301 检查映射的大小
在映射中检查键
遍历映射的键值对
auto 关键字
STL 总结
TextureHolder 类
编写 TextureHolder 头文件
编写 TextureHolder 函数定义
TextureHolder 我们取得了什么成果?
构建一群僵尸
编写 Zombie.h 文件
编写 Zombie.cpp 文件
使用僵尸类创建一群僵尸
让一群僵尸复活(复活)
使用 TextureHolder 类处理所有纹理
更改背景获取纹理的方式
更改玩家获取纹理的方式
总结
常见问题解答
第十一章:碰撞检测、拾取和子弹
编写 Bullet 类
编写 Bullet 头文件
编写 Bullet 源文件
让子弹飞
包含 Bullet 类
控制变量和子弹数组
重新装填枪
射击子弹
每帧更新子弹
每帧绘制子弹
给玩家添加准星
编写拾取类
编写拾取类头文件
编写拾取类函数定义
使用拾取类
检测碰撞
371 有僵尸被射击吗?
375 玩家是否被僵尸触碰?
376 玩家是否触碰到拾取物?
377 摘要
377 常见问题解答
第十二章:分层视图和实现 HUD
379 添加所有文本和 HUD 对象
384 更新 HUD
387 绘制 HUD、主页和升级屏幕
390 摘要
391 常见问题解答
第十三章:音效、文件 I/O 和完成游戏
394 保存和加载高分
396 准备音效
398 升级
401 重新开始游戏
402 播放剩余的声音
402 玩家装弹时添加音效
403 制作射击声音
404 玩家被击中时播放声音
405 拾取物品时播放声音
406 射击僵尸时制作噗嗤声
408 摘要
408 常见问题解答
第十四章:抽象和代码管理 – 更好地使用面向对象编程
410 托马斯迟到了游戏
410 托马斯迟到了的功能
414 创建项目
416 项目的资源
419 结构化托马斯迟到了代码
421 构建游戏引擎
422 重复使用 TextureHolder 类
425 编写 Engine.h
429 编写 Engine.cpp
438 到目前为止的引擎类
439 编写主函数
441 摘要
441 常见问题解答
第十五章: 高级面向对象编程 – 继承和多态
444 继承
444 扩展类
447 多态
448 抽象类 – 虚函数和纯虚函数
450 构建 PlayableCharacter 类
451 编码 PlayableCharacter.h
456 编码 PlayableCharacter.cpp
462 构建 Thomas 和 Bob 类
462 编码 Thomas.h
463 编码 Thomas.cpp
466 编码 Bob.h
466 编码 Bob.cpp
469 更新游戏引擎以使用 Thomas 和 Bob
469 更新 Engine.h 以添加 Bob 和 Thomas 的实例
470 更新输入函数以控制 Thomas 和 Bob
471 更新 update 函数以生成和更新 PlayableCharacter 实例
476 绘制 Bob 和 Thomas
480 总结
481 常见问题解答
第十六章: 构建可玩关卡和碰撞检测
484 设计一些关卡
489 构建 LevelManager 类
489 编码 LevelManager.h
492 编码 LevelManager.cpp 文件
499 编码 loadLevel 函数
504 更新引擎
508 碰撞检测
508 编码 detectCollisions 函数
515 更多碰撞检测
517 总结
第十七章: 声音空间化和 HUD
520 什么是空间化?
520 发射器、衰减和监听器
521 使用 SFML 处理空间化
523 构建 SoundManager 类
524 编写 SoundManager.h
525 编写 SoundManager.cpp 文件
531 将 SoundManager 添加到游戏引擎
532 填充声音发射器
532 编写 populateEmitters 函数
535 播放声音
539 实现 HUD 类
539 编写 HUD.h
540 编写 HUD.cpp 文件
543 使用 HUD 类
547 总结
第十八章: 粒子系统与着色器
549 构建粒子系统
550 编写 Particle 类
553 编写 ParticleSystem 类
553 探索 SFML 的 Drawable 类和面向对象编程
556 Drawable 继承的替代方案
563 使用 ParticleSystem 对象
571 OpenGL、着色器与 GLSL
572 可编程管线与着色器
573 编写片段着色器
574 编写顶点着色器
575 向引擎类添加着色器
576 加载着色器
577 更新和绘制着色器
581 总结
第十九章: 游戏编程设计模式 – 开始 Space Invaders ++游戏
584 Space Invaders ++
587 为什么是 Space Invaders ++?
588 设计模式
588 屏幕、输入处理器、UI 面板和按钮
591 实体-组件模式
593 优先使用组合而非继承
595 工厂模式
597 C++ 智能指针
598 共享指针
599 唯一指针
600 智能指针的转换
602 C++ 断言
603 创建 Space Invaders ++ 项目
603 使用过滤器组织代码文件
605 添加 DevelopState 文件
605 编写 SpaceInvaders ++.cpp
606 编写 GameEngine 类
609 编写 SoundEngine 类
612 编写 ScreenManager 类
616 编写 BitmapStore 类
619 编写 ScreenManagerRemoteControl 类
620 我们现在在哪里?
620 编写 Screen 类及其依赖项
620 编写 Button 类
622 编写 UIPanel 类
627 编写 InputHandler 类
633 编写 Screen 类
637 添加 WorldState.h 文件
638 编写选择屏幕的派生类
638 编写 SelectScreen 类
641 编写 SelectInputHandler 类
643 编写 SelectUIPanel 类
646 编写游戏屏幕的派生类
646 编写 GameScreen 类
650 编写 GameInputHandler 类
652 编写 GameUIPanel 类
654 编写 GameOverInputHandler 类
656 编写 GameOverUIPanel 类
658 运行游戏
660 总结
第二十章:游戏对象和组件
662 准备编写组件
662 编写组件基类
664 编写碰撞组件
664 编写 ColliderComponent 类
666 编写 RectColliderComponent 类
669 编写图形组件
669 编写 GraphicsComponent 类
671 编写 StandardGraphicsComponent 类
674 编写 TransformComponent 类
677 编写更新组件
677 编写 UpdateComponent 类
679 编写 BulletUpdateComponent 类
684 编写 InvaderUpdateComponent 类
693 编写 PlayerUpdateComponent 类
699 编写 GameObject 类
707 解释 GameObject 类
716 摘要
第二十一章:文件 I/O 和游戏对象工厂
718 文件 I/O 和工厂类的结构
720 描述世界中的对象
723 编写 GameObjectBlueprint 类
727 编写 ObjectTags 类
729 编写 BlueprintObjectParser 类
734 编写 PlayModeObjectLoader 类
736 编写 GameObjectFactoryPlayMode 类
741 编写 GameObjectSharer 类
742 编写 LevelManager 类
747 更新 ScreenManager 和 ScreenManagerRemote Control 类
749 我们现在在哪里?
750 摘要
第二十二章:使用游戏对象和构建游戏
752 生成子弹
752 编写 BulletSpawner 类
753 更新 GameScreen.h
756 处理玩家的输入
760 使用游戏手柄
763 编写 PhysicsEnginePlayMode 类
774 制作游戏
781 理解执行流程和调试
重新使用代码制作不同游戏和构建设计模式
786 摘要
第二十三章:在出发之前……
谢谢!
你可能喜欢的其他书籍
前言
本书旨在以有趣的方式为你介绍游戏编程、C++以及 OpenGL 驱动的 SFML 的世界,通过五个难度递增、功能不断升级的有趣、可玩的游戏来实现。这些游戏包括一个上瘾的、急速的双按钮敲击游戏、一个 Pong 游戏、一个多级僵尸生存射击游戏、一个分屏多人解谜平台游戏和一个射击游戏。
在这个改进和扩展的第二版中,我们将从编程的基础开始,例如变量、循环和条件,随着你通过关键 C++主题,如面向对象编程(OOP)、C++指针以及标准模板库(STL)的介绍,你将变得越来越熟练。在构建这些游戏的过程中,你还将学习到令人兴奋的游戏编程概念,例如粒子效果、方向性声音(空间化)、OpenGL 可编程着色器、如何生成成千上万的对象等等。
这本书面向的对象
如果你符合以下任何一种情况,这本书非常适合你:你完全没有任何 C++编程知识,或者需要入门级别的复习课程;你想学习如何构建游戏,或者只是想将游戏作为一种有趣的学习 C++的方式;如果你有有一天发布游戏的抱负,也许是在 Steam 上,或者你只是想尽情享受乐趣,并用你的创作给朋友们留下深刻印象。
本书涵盖的内容
第一章,C++、SFML、Visual Studio 和开始第一个游戏,是一个相当庞大的第一章,但我们将学习所有我们需要的东西,以便让我们的第一个游戏的第一部分能够运行起来。以下是我们要做的事情:了解我们将要构建的游戏,了解 C++,了解微软 Visual C++,了解 SFML 及其与 C++的关系,设置开发环境,计划和准备第一个游戏项目 Timber!!!,在书中编写第一段 C++代码,并制作一个可以运行的游戏,该游戏可以绘制背景。
第二章,变量、运算符和决策 – 动画精灵,涉及更多的屏幕绘制,为了实现这一点,我们需要学习一些 C++的基础知识。我们将学习如何使用变量来记住和操作值,我们还将开始为游戏添加更多图形。随着章节的推进,我们将看到如何操作这些值来动画化图形。这些值被称为变量。
第三章,C++ 字符串和 SFML 时间 – 玩家输入和 HUD,继续介绍 Timber!!! 游戏。我们将用一半的章节学习如何操作文本并在屏幕上显示它,另一半将探讨时间管理以及如何通过视觉时间条来告知玩家并营造游戏的紧迫感。我们将涵盖以下内容:暂停和重新启动游戏,C++ 字符串,SFML 文本和 SFML 字体类,为 Timber!!! 添加 HUD,以及为 Timber!!! 添加时间条。
第四章**,循环、数组、开关、枚举和函数 – 实现游戏机制,可能比书中任何其他章节都包含更多的 C++ 信息。它充满了将极大地提高我们理解的基本概念。它还将开始揭示我们之前略过的一些模糊区域,例如函数和游戏循环。一旦我们探索了整个 C++ 语言必需品列表,我们就会利用我们所知道的一切来制作主要游戏机制——树枝——移动。到本章结束时,我们将为最终阶段和 Timber!!! 的完成做好准备。这是我们将在本章中探讨的内容:循环、数组、使用开关做出决策、枚举、开始使用函数,以及创建和移动树枝。
第五章,碰撞、声音和结束条件 – 使游戏可玩,构成了第一个项目的最后阶段。到本章结束时,你将拥有你的第一个完成的游戏。一旦 Timber!!! 运行起来,请务必阅读本章的最后部分,因为它将建议如何使游戏变得更好。在本章中,我们将涵盖以下主题:添加剩余的精灵,处理玩家输入,动画飞行木块,处理死亡,添加音效,添加功能,并改进 Timber!!!。
第六章,面向对象编程 – 开始 Pong 游戏,包含相当大量的理论,但理论将为我们提供使用 OOP 强力效果的知识。此外,我们不会浪费时间将理论应用于编码下一个项目,一个 Pong 游戏。我们将了解如何通过编写一个类来创建新类型,这些类型作为对象使用。我们将首先查看一个简化的 Pong 场景来学习一些类的基本知识,然后我们将重新开始并使用我们学到的原则来编写一个真正的 Pong 游戏。
第七章,动态碰撞检测和物理 – 完成乒乓球游戏,解释了如何编写我们的第二个类。我们将看到,尽管球显然与球拍有很大不同,但我们仍然会使用完全相同的技术,将球的外观和功能封装在 Ball
类中,就像我们处理球拍和 Bat
类一样。然后,我们将通过编写一些动态碰撞检测和计分来完善乒乓球游戏。这听起来可能很复杂,但正如我们所期待的,SFML 将使事情比其他方式更容易。
第八章**,SFML 视图 – 开始僵尸射击游戏,解释了该项目如何更充分地利用面向对象编程(OOP),并取得了显著的效果。我们还将探讨 SFML 的 View
类。这个多功能的类将使我们能够轻松地将游戏分层,以适应游戏的不同方面。在僵尸射击项目中,我们将有一个用于 HUD 的层和一个用于主游戏的层。这是必要的,因为随着游戏世界的每次扩展,玩家清除一波僵尸后,游戏世界最终将比屏幕大,需要滚动。使用视图类可以防止 HUD 中的文本与背景一起滚动。在下一个项目中,我们将更进一步,使用 SFML 视图类创建一个合作分屏游戏,视图类将完成大部分繁重的工作。这就是本章我们将要完成的内容:规划僵尸竞技场游戏,编写 Player
类,学习 SFML 视图类,并构建僵尸竞技场游戏引擎,使玩家类发挥作用。
第九章,C++ 引用、精灵图集和顶点数组,探讨了 C++引用,它允许我们处理那些通常不在作用域内的变量和对象。此外,引用将帮助我们避免在函数之间传递大型对象,这是一个缓慢的过程。这是因为每次我们这样做时,都必须创建变量或对象的副本。掌握了关于引用的新知识后,我们将研究 SFML 的 VertexArray
类,它允许我们构建一个大型图像,可以通过单个图像文件中的多个部分快速高效地绘制到屏幕上。到本章结束时,我们将使用引用和 VertexArray
对象构建一个可伸缩的、随机的、滚动的背景。
第十章,指针、标准模板库和纹理管理,首先介绍了 C++的基本主题——指针。指针是存储内存地址的变量。通常,指针将存储另一个变量的内存地址。这听起来有点像引用,但我们将看到它们要强大得多,我们将使用指针来处理不断增长的僵尸群。我们还将了解 STL,它是一组类,允许我们快速轻松地实现常见的数据管理技术。一旦我们理解了 STL 的基础,我们就能使用新获得的知识来管理游戏中的所有纹理,因为如果我们有 1,000 个僵尸,我们真的不希望为每个僵尸加载一个图形副本到 GPU 中。我们还将更深入地探讨面向对象编程,并使用静态函数,这是一个可以在没有类实例的情况下调用的类函数。同时,我们将看到如何设计一个类,以确保只能存在一个实例。当我们需要确保代码的不同部分使用相同的数据时,这是理想的。
第十一章,碰撞检测、拾取和子弹,解释了到目前为止我们是如何实现游戏的主要视觉方面的。我们有一个可控的角色在一个满是僵尸的竞技场中奔跑,这些僵尸会追逐他。问题是它们之间没有互动。一个僵尸可以毫无阻碍地穿过玩家而不会留下任何痕迹。我们需要检测僵尸和玩家之间的碰撞。如果僵尸能够伤害并最终杀死玩家,那么给玩家一些子弹是公平的。然后我们需要确保子弹能够击中和杀死僵尸。同时,如果我们正在编写子弹、僵尸和玩家的碰撞检测代码,那么添加一个用于健康和弹药拾取的类也是一个好时机。
第十二章,分层视图和实现 HUD,是我们将看到 SFML 视图真正价值的章节。我们将添加大量 SFML Text
对象,并像在 Timber 项目和 Pong 项目中那样操作它们。新的地方在于,我们将使用第二个视图实例来绘制 HUD。这样,无论背景、玩家、僵尸和其他游戏对象在做什么,HUD 都会整齐地定位在主要游戏动作的上方。
第十三章, 音效、文件输入/输出和完成游戏,展示了我们如何使用 C++标准库轻松地操作硬盘上存储的文件,并且我们还将添加音效。当然,我们知道如何添加音效,但我们将讨论代码中调用播放函数的确切位置。我们还将解决一些悬而未决的问题,使游戏完整。在本章中,我们将做以下事情:使用文件输入和文件输出保存和加载高分,添加音效以允许玩家升级,并创建无限循环的多波次。
第十四章, 抽象和代码管理 – 更好地利用面向对象编程,专注于启动托马斯独自一个项目,特别是探索代码将如何结构化以更好地利用面向对象编程。以下是本章将涵盖的主题的详细信息:介绍了最终项目,托马斯迟到,包括游戏玩法功能和项目资产,并详细讨论了与之前项目相比,我们将如何改进代码结构,编写托马斯迟到游戏引擎,并实现分屏功能。
第十五章, 高级面向对象编程 – 继承和多态,通过探讨稍微高级一些的继承和多态概念,进一步扩展了我们对于面向对象编程的知识。然后,我们将能够运用这些新知识来实现游戏中的主角,托马斯和鲍勃。以下是本章将涵盖的内容:学习如何通过继承扩展和修改一个类,通过使用多态将一个类的对象视为多种类型的类,了解抽象类以及设计永远不会实例化的类实际上可能是有用的,构建一个抽象的 PlayableCharacter
类,将继承应用于 Thomas
和 Bob
类,并将托马斯和鲍勃添加到游戏项目中。
第十六章,构建可玩关卡和碰撞检测,可能会证明是本项目中最令人满意的一章。原因在于,到那时,我们将拥有一个可玩的游戏。尽管仍有一些功能需要实现(声音、粒子效果、HUD 和着色器效果),Bob 和 Thomas 将能够跑步、跳跃和探索世界。此外,你只需在文本文件中创建平台和障碍物,就可以简单地创建任何大小或复杂性的自己的关卡设计。我们将通过以下主题实现所有这些:探索如何在文本文件中设计关卡,构建一个LevelManager
类,从文本文件中加载关卡,将它们转换为我们的游戏可以使用的数据,并跟踪关卡细节,如出生位置、当前关卡和允许的时间限制,更新游戏引擎以使用LevelManager
,并编写一个多态函数来处理 Bob 和 Thomas 的碰撞检测。
第十七章,声音空间化和 HUD,添加了所有声音效果和 HUD。我们已经在之前的两个项目中这样做过,但这次我们会有些不同。我们将探讨声音空间化的概念以及 SFML 如何使这个原本复杂的概念变得简单易行。此外,我们将构建一个 HUD 类来封装我们绘制到屏幕上的代码。我们将按以下顺序完成任务:空间化的定义,SFML 如何处理空间化,构建一个SoundManager
类,部署发射器,使用SoundManager
类,以及构建和使用一个 HUD 类。
第十八章,粒子系统和着色器,探讨了粒子系统是什么,然后继续将其编码到我们的游戏中。我们将浅析 OpenGL 着色器这一主题,并看看使用另一种语言OpenGL 着色语言(GLSL),它可以直接在图形卡上运行,如何产生可能在其他情况下无法实现的平滑图形效果。像往常一样,我们也将利用我们新的技能和知识来增强当前项目。
第十九章,游戏编程设计模式 – 开始 Space Invaders ++游戏,介绍了最终项目。正如你现在所期望的,这个项目将学习新的 C++技术迈出重要一步。接下来的四章将探讨包括智能指针、C++断言、使用游戏手柄控制器、使用 Visual Studio 进行调试、将基类指针转换为特定派生类指针、调试以及初步了解设计模式等主题。作者推测,如果你打算用 C++制作深度、大规模的游戏,那么设计模式将是你在未来几个月和几年学习计划中的一个重要部分。为了介绍这个至关重要的主题,我选择了一个相对简单但有趣的游戏作为例子。让我们更深入地了解一下 Space Invaders ++游戏,然后我们可以继续讨论设计模式及其必要性。在本章中,我们将涵盖以下主题:了解 Space Invaders ++及其为何被选为最终项目,学习设计模式是什么以及为什么它们对游戏开发者很重要,研究在接下来的四章中将在 Space Invaders ++项目中使用的各种设计模式,开始 Space Invaders ++项目,并编写许多类以开始完善游戏。
第二十章,游戏对象和组件,涵盖了我们在上一章开头讨论的实体-组件模式的所有编码。这意味着我们将编写所有其他组件都将从中派生的基本组件类。我们还将充分利用我们关于智能指针的新知识,这样我们就不必担心跟踪为这些组件分配的内存。我们还将在本章中编写GameObject
类。以下是本章的章节列表:准备编写组件,编写组件基类,编写碰撞组件,编写图形组件,编写更新组件,以及编写GameObject
类。
第二十一章,文件 I/O 和游戏对象工厂,解释了GameObject
如何进入游戏中使用的m_GameObjects
向量。我们将看到如何在一个文本文件中描述单个对象和整个关卡。我们将编写代码来解释文本,然后将值加载到一个将成为游戏对象蓝图 的类中。我们将编写一个名为LevelManager
的类,它将监督整个过程,从InputHandler
通过ScreenManager
发送的初始请求加载关卡开始,一直到使用工厂模式从组件组装游戏对象并将其整齐地打包到m_GameObjects
向量中的LevelManager
类。
第二十二章,使用游戏对象和构建游戏,构成了 Space Invaders ++项目的最后阶段。我们将学习如何使用 SFML 从游戏手柄接收输入来完成所有困难的工作,我们还将编写一个类来处理入侵者和GameScreen
类之间的通信,以及玩家和GameScreen
类之间的通信。这个类将允许玩家和入侵者发射子弹,但同样的技术也可以用于您游戏中不同部分之间所需的任何类型的通信,因此了解它是很有用的。游戏的最后部分(就像往常一样)将是碰撞检测和游戏本身的逻辑。一旦 Space Invaders ++运行起来,我们将学习如何使用 Visual Studio 调试器,这在您设计自己的逻辑时将非常有价值,因为它允许您逐行执行代码并查看变量的值。它也是研究我们在整个项目过程中构建的模式执行流程的有用工具。
第二十三章,在离开之前...,结束了我们的旅程。当您第一次打开这本书时,可能觉得最后一页似乎还很遥远。但希望这并不太难?重点是您现在在这里,并且希望您对如何使用 C++构建游戏有了深刻的理解。听到即使在这数百页之后,我们才刚刚涉足 C++,可能会让您感到惊讶。甚至我们涵盖的主题也可以更深入地探讨,而且还有许多,其中一些相当重要,我们甚至没有提及。考虑到这一点,让我们看看接下来可能是什么。
为了充分利用这本书
需要满足以下要求:
-
Windows 7 Service Pack 1、Windows 8 或 Windows 10
-
1.6 GHz 或更快的处理器
-
1 GB 的 RAM(x86 架构)或 2 GB 的 RAM(x64 架构)
-
15 GB 的可用硬盘空间
-
5400 RPM 的硬盘驱动器
-
具有 DirectX 9 功能的显卡,支持 1024 x 768 或更高显示分辨率
本书使用的所有软件都是免费的。获取和安装软件的步骤在书中都有详细说明。本书在 Windows 上使用 Visual Studio,但经验丰富的 Linux 和 Mac 用户在使用他们喜欢的编程环境运行代码和遵循说明时可能不会有任何困难。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在
www.packt.com
登录或注册。 -
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Beginning-Cpp-Game-Programming-Second-Edition
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/
获取。查看它们吧!
下载彩色图片
我们还提供了一份包含本书中使用的截图/图表的彩色图片的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838648572_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
CodeInText
: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“我的主要项目目录是 D:\VS Projects\Timber
。”
代码块设置如下:
int main()
{
return 0;
}
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
int main()
{
return 0;
}
粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个例子:“点击创建新项目按钮。”
重要提示
警告或重要提示如下所示。
小贴士
小技巧和技巧如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈: 如果你对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 给我们发送邮件。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/support/errata,选择你的书,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
关于 Packt 的更多信息,请访问 packt.com。
第二章:第一章:C++、SFML、Visual Studio 和开始第一个游戏
欢迎来到开始 C++游戏编程。我不会浪费时间,将你引入使用 C++和 OpenGL 驱动的 SFML 编写 PC 游戏之旅。
这第一章内容相当丰富,但我们将学习我们需要的所有内容,以便我们的第一个游戏的第一部分能够运行起来。以下是本章我们将要做什么:
-
了解我们将要构建的游戏
-
了解 C++
-
了解 Microsoft Visual C++
-
探索 SFML 及其与 C++的关系
-
设置开发环境
-
为第一个游戏项目 Timber!!!制定计划和准备
-
编写本书的第一段 C++代码,并制作一个可以绘制背景的可运行游戏
我们将要构建的游戏
这段旅程将会很顺利,因为我们将会一步一步地学习超级快速 C++语言的基础知识,然后通过为我们将要构建的五个游戏添加酷炫功能来应用这些新知识。
以下是本书的五个项目。
Timber!!!
第一个游戏是一个令人上瘾、节奏快速的 Timberman 克隆版,你可以在store.steampowered.com/app/398710/
找到它。我们的游戏,Timber!!!,将在构建一个真正可玩的游戏的同时,让我们了解 C++的所有基础知识。以下是完成版本的游戏以及我们添加的一些最后时刻的增强功能将看起来像什么:
Pong
Pong 是首批制作出的视频游戏之一,你可以在en.wikipedia.org/wiki/Pong
了解其历史。它是游戏对象动画和动态碰撞检测基础的一个极好例子。我们将构建这个简单的复古游戏来探索类和面向对象编程的概念。玩家将使用屏幕底部的球拍,将球击回屏幕顶部:
Zombie Arena
接下来,我们将构建一个疯狂、僵尸生存射击游戏,与 Steam 热门游戏Over 9,000 Zombies!非常相似,你可以在store.steampowered.com/app/273500/
了解更多信息。玩家将拥有机关枪,必须击退不断增多的僵尸浪潮。所有这一切都将发生在一个随机生成的滚动世界中。为了实现这一点,我们将学习面向对象编程如何使我们拥有一个大型代码库(大量代码),易于编写和维护。期待以下令人兴奋的功能:数百个敌人、快速射击武器、拾取物品以及每个波次后可以“升级”的角色:
托马斯迟到了
第四款游戏将是一款时尚且具有挑战性的单人及合作解谜平台游戏。它基于非常受欢迎的游戏 托马斯一个人 (store.steampowered.com/app/220780/
)。你将有机会了解一些酷炫的话题,例如粒子效果、OpenGL 着色器以及分屏合作多人游戏:
小贴士
如果你现在想玩任何游戏,你可以从“可运行游戏”文件夹中的下载包中进行。只需双击相应的.exe
文件即可。请注意,在这个文件夹中,你可以运行任何章节中完成的游戏或任何处于部分完成状态的游戏。
空间入侵者++
最终的游戏将是一个空间入侵者克隆版。在某种程度上,游戏本身并不是这个项目最重要的部分。这个项目将用于学习游戏编程模式。随着本书的进展,这一点将变得非常明显,我们的代码变得越来越长、越来越复杂。每个项目都将介绍一种或多种应对这种复杂性的技术,但我们的代码的复杂性和长度将持续挑战我们,尽管有这些技术。
空间入侵者项目(称为空间入侵者++)将向我们展示如何彻底重新组织我们的游戏代码,以及我们如何最终控制并妥善管理我们的代码。这将让你拥有计划构建深度、复杂和创新游戏所需的所有知识,而不会陷入代码的混乱之中。
游戏还将引入屏幕、输入处理器和实体-组件系统等概念。它还将让我们学习如何让玩家使用游戏手柄而不是键盘,并介绍 C++的智能指针、类型转换、断言、断点调试等概念,并教给我们整本书最重要的教训:如何构建你自己的独特游戏:
让我们从介绍 C++、Visual Studio 和 SFML 开始吧!
认识 C++
既然我们已经知道了我们将要构建的游戏,让我们通过介绍 C++、Visual Studio 和 SFML 来开始吧。你可能会有一个问题,为什么要使用 C++语言呢? C++运行速度快——非常快。这背后的原因是,我们编写的代码被直接转换成机器可执行的指令。这些指令就是游戏。可执行的游戏包含在一个.exe
文件中,玩家只需双击即可运行。
将我们的代码转换为可执行文件的过程中有几个步骤。首先,预处理器会检查是否需要将任何其他代码包含在我们的代码中,并将其添加进去。接下来,所有代码都被编译器程序编译成目标文件。最后,一个名为链接器的第三方程序将所有目标文件连接成我们的游戏的可执行文件。
此外,C++在同时被广泛采用的同时,也极为更新。C++是一种面向对象编程(OOP)语言,这意味着我们可以使用经过良好测试的约定来编写和组织我们的代码,使我们的游戏高效且易于管理。随着我们阅读本书的进展,这种好处以及必要性将显现出来。
我所提到的这其他代码,正如你可能猜到的,大部分是 SFML,我们将在下一分钟内了解更多关于 SFML 的信息。我刚才提到的预处理器、编译器和链接器程序都是 Visual Studio 集成开发环境(IDE)的一部分。
Microsoft Visual Studio
Visual Studio 隐藏了预处理器、编译和链接的复杂性。它将所有这些封装在一个按钮的按下中。此外,它还提供了一个流畅的用户界面,让我们可以输入代码,并管理将成为大量代码文件和其他项目资产的项目。
虽然有高级版本的 Visual Studio,价格高达数百美元,但我们将能够在免费的“Express 2019 for Community”版本中构建我们所有的五个游戏。这是 Visual Studio 的最新免费版本。
SFML
SFML是简单快速媒体库。它不是唯一的游戏和多媒体 C++库。可以争论使用其他库,但 SFML 似乎每次都能满足我的需求。首先,它使用面向对象的 C++编写。面向对象 C++的好处很多,你将在阅读本书的过程中体验到它们。
SFML 也易于入门,因此如果你是初学者,这是一个不错的选择;同时,如果你是专业人士,它也有潜力构建最高质量的 2D 游戏。所以,初学者可以使用 SFML 开始,不必担心随着经验的增长需要重新开始使用新的语言/库。
最大的好处可能就是,大多数现代 C++编程都使用面向对象编程(OOP)。我读过的每一本 C++初学者指南都使用并教授 OOP。实际上,在几乎所有语言中,OOP 都是编程的未来(以及现在)。那么,如果你是从头开始学习 C++,你为什么要用其他方式呢?
SFML 有一个模块(代码),几乎可以完成你在 2D 游戏中想要做的任何事情。SFML 使用 OpenGL,也可以制作 3D 游戏。当你想在多个平台上运行时,OpenGL 是事实上的免费图形库。当你使用 SFML 时,你自动使用了 OpenGL。
SFML 允许你创建以下内容:
-
2D 图形和动画,包括滚动游戏世界。
-
声音效果和音乐播放,包括高质量的方向性声音。
-
使用键盘、鼠标和游戏手柄进行输入处理。
-
在线多人游戏功能。
-
同一段代码可以在所有主要的桌面操作系统上编译和链接,包括移动设备!。
广泛的研究没有发现更多适合为 PC 构建 2D 游戏的方法,即使是对于经验丰富的开发者,尤其是如果你是一个想在一个有趣的游戏环境中学习 C++ 的初学者。
在接下来的章节中,我们将设置开发环境,首先讨论如果你使用的是 Mac 或 Linux 操作系统时应该做什么。
设置开发环境
现在你对我们如何制作游戏有了更多了解,是时候设置一个开发环境,以便我们可以开始编码了。
那么 Mac 和 Linux 呢?
我们将要制作的游戏可以被构建为在 Windows、Mac 和 Linux 上运行!我们使用的代码在每个平台上都是相同的。然而,每个版本确实需要针对它打算运行的平台进行编译和链接,而 Visual Studio 将无法帮助我们处理 Mac 和 Linux。
对于初学者来说,说这本书完全适合 Mac 和 Linux 用户是不公平的。尽管如此,我想,如果你是一个热情的 Mac 或 Linux 用户,并且你对你的操作系统感到舒适,你很可能会成功。你将遇到的绝大多数额外挑战都将出现在开发环境、SFML 和第一个项目的初始设置中。
为了达到这个目的,我强烈推荐以下教程,希望它们能替代接下来的 10 页(大约),直到 规划木材!!! 这一部分,届时这本书将适用于所有操作系统。
对于 Linux,阅读以下内容以替换接下来的几节:www.sfml-dev.org/tutorials/2.5/start-linux.php
。
在 Mac 上,阅读这篇教程以开始:www.sfml-dev.org/tutorials/2.5/start-osx.php
。
安装 Visual Studio 2019 社区版
要开始创建游戏,我们需要安装 Visual Studio 2019。安装 Visual Studio 可以几乎像下载一个文件并点击几个按钮一样简单。我将一步一步地引导你完成安装过程。
重要提示
注意,多年来,微软可能会更改 Visual Studio 的名称、外观和下载页面,用于获取 Visual Studio 的页面。他们可能会更改用户界面的布局,并使接下来的说明过时。然而,我们为每个项目配置的设置对于 C++ 和 SFML 是基本的,因此,即使微软对 Visual Studio 进行了激进的改变,仔细解读本章接下来的说明也应该是可能的。无论如何,在写作的时候,Visual Studio 2019 已经发布了仅仅两周,所以希望这一章会保持更新一段时间。如果发生了重大事件,那么一旦我发现,我就会在 gamecodeschool.com
上添加一个最新的教程。
让我们开始安装 Visual Studio:
-
你首先需要一个 Microsoft 账户和登录详情。如果你有 Hotmail 或 MSN 电子邮件地址,那么你已经有一个了。如果没有,你可以在这里免费注册一个:
login.live.com/
。 -
下一步是访问
visualstudio.microsoft.com/vs/
并找到 Community 2019 的下载链接。这是我写作时的样子! -
将文件保存到你的电脑上。
-
下载完成后,通过双击它来运行下载文件。在我写作的时候,我的文件被命名为
vs_community__33910147.1551368984.exe
。你的文件名将根据当前版本的 Visual Studio 而有所不同。 -
在授予 Visual Studio 允许更改你的电脑的权限后,你将看到一个如下所示的窗口。点击 继续:
-
等待安装程序下载一些文件并设置安装的下一阶段。不久,你将看到一个如下所示的窗口:
-
如果你想要选择一个新的位置来安装 Visual Studio,找到 更改 选项并配置安装位置。最简单的事情就是将文件留在 Visual Studio 默认选择的位置。准备好后,找到 使用 C++ 进行桌面开发 选项并选择它。
-
接下来,点击 安装 按钮。准备一些小吃,因为这个步骤可能需要一段时间。
-
当过程完成后,你可以关闭所有打开的窗口,包括任何提示你开始新项目的窗口,因为我们还没有准备好开始编码,直到我们安装了 SFML。
现在,我们将把注意力转向 SFML。
设置 SFML
这个简短的教程将指导你下载 SFML 文件,这些文件允许我们将库中的功能包含到我们的项目中。此外,我们还将了解如何使用 SFML DLL 文件,这将使我们的编译对象代码能够与 SFML 一起运行。要设置 SFML,请按照以下步骤操作:
-
访问 SFML 网站的此链接:
www.sfml-dev.org/download.php
。点击此处所示的 最新稳定版本 按钮 -
到你阅读这本书的时候,最新版本几乎肯定已经改变了。只要你的下一步操作正确,这并不重要。我们想要下载 32 位版本 的 Visual C++ 2017。这可能听起来有些不合逻辑,因为我们刚刚安装了 Visual Studio 2019,你很可能(最常见的情况)有一个 64 位 PC。我们选择下载 32 位版本的原因是 Visual C++ 2017 是 Visual Studio 2019 的一部分(Visual Studio 做的不仅仅是 C++),我们将以 32 位构建游戏,这样它们就可以在 32 位 和 64 位 机器上运行。点击以下截图所示的 下载 按钮
-
当下载完成时,在您安装 Visual Studio 的同一驱动器的根目录下创建一个文件夹,并将其命名为
SFML
。同样,在您安装 Visual Studio 的驱动器的根目录下创建另一个文件夹,并将其命名为VS Projects
。 -
最后,解压 SFML 下载文件。请在您的桌面上进行此操作。解压完成后,您可以删除.zip 文件夹。您将剩下桌面上的一个单独文件夹。其名称将反映您下载的 SFML 版本。我的文件夹名为
SFML-2.5.1-windows-vc15-32-bit
。您的文件名可能反映一个更新的版本。双击此文件夹以查看其内容,然后再次双击进入下一个文件夹(我的文件夹名为SFML-2.5.1
)。以下截图显示了SFML-2.5.1
文件夹的内容。您的文件夹内容应该相同: -
复制此文件夹的全部内容,并将所有文件和文件夹粘贴到您在步骤 3中创建的
SFML
文件夹中。在此书余下的部分,我将简单地称这个文件夹为“您的SFML
文件夹”。
现在,我们已准备好开始在 Visual Studio 中使用 C++ 和 SFML。
创建新项目
由于设置项目是一个繁琐的过程,我们将一步一步地进行,以便我们可以开始习惯这个过程:
-
以您启动任何应用程序的方式启动 Visual Studio:通过点击其图标。默认安装选项将在 Windows 开始菜单中放置一个Visual Studio 2019图标。您将看到以下窗口:
-
点击前一张截图中所突出的创建新项目按钮。您将看到创建新项目窗口,如下所示:
-
在创建新项目窗口中,我们需要选择我们将要创建的项目类型。我们将创建一个控制台应用程序,因此选择控制台应用程序,如前一张截图所示,然后点击下一步按钮。接下来,您将看到配置新项目窗口。以下截图显示了完成接下来的三个步骤后的配置新项目窗口:
-
在项目名称字段中输入
Timber
。请注意,这会导致 Visual Studio 自动将解决方案名称字段配置为相同的名称。 -
在我们之前教程中创建的
VS Projects
文件夹中。这将是我们所有项目文件存放的位置。 -
选择将解决方案和项目放在同一目录下的选项。
-
注意,前一张截图显示了完成前三个步骤后窗口的外观。当您完成这些步骤后,点击创建。项目将被生成,包括一些 C++ 代码。以下截图显示了本书余下的工作区域:
-
我们现在将配置项目以使用我们放在
SFML
文件夹中的 SFML 文件。从主菜单中选择项目 | Timber 属性…。你会看到以下窗口:
小贴士
在前面的截图中,确定、取消和应用按钮没有完全显示。这可能是 Visual Studio 没有正确处理我的屏幕分辨率导致的错误。希望你的按钮能够完全显示。无论你的按钮是否像我的一样,继续教程的步骤将是相同的。
接下来,我们将开始配置项目属性。由于这些步骤相当复杂,我将用新的步骤列表来介绍。
配置项目属性
在此阶段,你应该已经打开了Timber 属性页窗口,如前一个部分末尾的截图所示。现在,我们将开始配置一些属性,同时使用以下带注释的截图作为指导:
在本节中,我们将添加一些相当复杂且重要的项目设置。这是比较繁琐的部分,但每个项目我们只需要做一次。我们需要做的是告诉 Visual Studio 在哪里可以找到 SFML 的特殊类型代码文件。我指的是具有.hpp
扩展名的特殊文件。当我们最终开始添加自己的头文件时,这一切都会变得清晰起来。此外,我们还需要告诉 Visual Studio 在哪里可以找到 SFML 的库文件。在Timber 属性页窗口中,执行以下三个步骤,这些步骤在先前的截图中有编号:
-
首先(1),从配置:下拉菜单中选择所有配置。
-
第二步(2),从左侧菜单中选择C/C++然后选择常规。
-
第三步(
\SFML\include
。如果你将SFML
文件夹放在 D 盘上,需要输入的完整路径,如前面的截图所示;即D:\SFML\include
。如果你的 SFML 安装在不同的驱动器上,请相应地更改路径)。 -
点击应用以保存到目前为止的配置。
-
现在,仍然在这个窗口中,执行以下步骤,这些步骤参考以下带注释的截图。首先(1),选择链接器然后选择常规。
-
现在,找到
SFML
文件夹的位置,然后是\SFML\lib
。所以,如果你将SFML
文件夹放在 D 盘上,需要输入的完整路径,如以下截图所示,是D:\SFML\lib
。如果你的 SFML 安装在不同的驱动器上,请相应地更改路径! -
点击应用以保存到目前为止的配置。
-
最后,在这个阶段,仍然在这个窗口中,执行以下步骤,这些步骤参考以下带注释的截图。将配置:下拉菜单(1)切换到调试,因为我们将在调试模式下运行和测试我们的游戏。
-
选择链接器然后选择输入(2)。
-
在指定位置找到
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。务必将光标放在正确的位置,并且不要覆盖任何已经存在的文本。 -
点击 OK:
-
点击 Apply 然后点击 OK。
呼;这就结束了!我们已经成功配置了 Visual Studio,可以继续规划 Timber!!! 项目。
规划木材!!!
每次你制作游戏时,最好从铅笔和纸开始。如果你不知道你的游戏如何在屏幕上工作,你怎么可能用代码让它工作呢?
小贴士
到目前为止,如果你还没有的话,我建议你去看一下 Timberman 的视频,这样你就可以看到我们想要达到的目标。如果你觉得你的预算可以扩展到这个程度,那么就抓起一份来试玩一下。它通常在 Steam 上以低于 1 美元的价格出售:store.steampowered.com/app/398710/
。
定义游戏玩法的游戏功能和对象被称为 机制。游戏的基本机制如下:
-
时间总是不够用。
-
你可以通过砍伐树木来获得更多时间。
-
砍伐树木会导致树枝掉落。
-
玩家必须避开掉落的树枝。
-
重复进行,直到时间用完或玩家被压扁。
在这个阶段期望你规划 C++ 代码显然有点愚蠢。这当然是 C++ 初学者指南的第一章。然而,我们可以查看我们将要使用的所有资产以及我们将需要让我们的 C++ 代码执行的大致概述。
看看这个游戏的标注截图:
你可以看到我们具有以下功能:
-
玩家的得分: 每次玩家砍倒一根木头,他们将得到一分。他们可以用左箭头或右箭头(光标)键砍倒木头。
-
玩家角色: 每次玩家砍伐,他们都会移动到/停留在与鼠标键相对的同一侧的树木上。因此,玩家必须小心他们选择砍伐的侧面。
-
当玩家砍伐时,一个简单的斧头图形会出现在玩家角色的手中。
-
缩小的计时条: 每次玩家砍伐,计时条上都会增加一小段时间。
-
致命的树枝: 玩家砍伐得越快,他们得到的时间越多,但树枝也会更快地沿着树木向下移动,因此他们被压扁的可能性也越大。树枝在树的顶部随机生成,并且每次砍伐时向下移动。
-
当玩家被压扁——而且他们会被经常压扁——会出现一个墓碑图形。
-
砍伐的木头: 当玩家砍伐时,一个砍伐的木头图形会飞离玩家。
-
仅作装饰: 有三朵漂浮的云彩将在随机的高度和速度下移动,还有一只除了飞来飞去什么都不做的蜜蜂。
-
背景:所有这些都在一个相当漂亮的背景上发生。
因此,简而言之,玩家必须疯狂地砍伐以获得分数并避免耗尽时间。作为稍微有些古怪但有趣的结果,他们砍伐得越快,他们柔软的死亡可能性就越大。
现在,我们已经知道了游戏的外观、玩法以及游戏机制背后的动机。现在,我们可以开始构建它了。按照以下步骤进行:
-
现在,我们需要将 SFML
.dll
文件复制到主项目目录中。我的主项目目录是D:\VS Projects\Timber
。这是在前一个教程中由 Visual Studio 创建的。如果你将VS Projects
文件夹放在其他地方,那么请在这里执行此步骤。我们需要复制到项目文件夹中的文件位于你的SFML\bin
文件夹中。为这两个位置打开一个窗口,并突出显示SFML\bin
文件夹中的所有文件,如下面的截图所示! -
现在,将突出显示的文件复制并粘贴到项目文件夹中,即
D:\VS Projects\Timber
。
项目现在已经设置好并准备就绪。你将能够看到以下屏幕。我已经注释了这个截图,以便你可以开始熟悉 Visual Studio。我们很快会重新访问所有这些区域以及其他区域:
你的布局可能看起来与前面的截图略有不同,因为 Visual Studio 的窗口,像大多数应用程序一样,是可以定制的。花点时间找到右侧的解决方案资源管理器窗口,并调整它,使其内容清晰易懂,就像前面的截图一样。
我们很快就会回来开始编码。但首先,我们将探索我们将要使用的项目资产。
项目资产
资产是你制作游戏所需的一切。在我们的例子中,这些资产包括以下内容:
-
屏幕上文字的字体
-
不同动作的声音效果,例如砍伐、死亡和耗尽时间
-
角色图形、背景、分支和其他游戏对象的图形
游戏所需的全部图形和声音都包含在这本书的下载包中。它们可以在相应的第一章/graphics
和第一章/sound
文件夹中找到。
所需的字体尚未提供。这是因为我想避免任何可能的版权模糊。尽管如此,这不会造成问题,因为我会向你展示如何精确地选择和下载字体。
虽然我会提供资产本身或获取它们的信息,但你可能喜欢自己创建或获取它们。
外包资产
有许多网站允许你雇佣艺术家、声音工程师,甚至程序员。其中最大的一个是 Upwork(www.upwork.com)。你可以免费加入这个网站并发布你的工作。你需要清楚地说明你的要求,以及你愿意支付多少。然后,你可能会得到很多承包商竞标来完成这项工作。然而,要注意的是,有很多不合格的承包商,他们的工作可能会令人失望,但如果你仔细选择,你很可能会找到一个有能力的、热情的、物有所值的人或公司来完成这项工作。
制作自己的声音效果
声音效果可以从 Freesound(www.freesound.org)等网站免费下载,但通常许可协议不允许你在销售游戏时使用它们。另一个选择是使用来自www.bfxr.net的开源软件 BFXR,它可以帮你生成大量不同的声音效果,这些效果你可以保留并随意使用。
将资源添加到项目中
一旦你决定使用哪些资源,就是时候将它们添加到项目中了。以下说明将假设你正在使用本书下载包中提供的所有资源。如果你使用的是自己的资源,只需用你的声音或图形文件替换相应的文件,使用完全相同的文件名:
-
浏览到项目文件夹,即
D:\VS Projects\Timber
。 -
在这个文件夹内创建三个新的文件夹,并分别命名为
graphics
、sound
和fonts
。 -
从下载包中,将
Chapter 1/graphics
文件夹的全部内容复制到D:\VS Projects\Timber\graphics
文件夹。 -
从下载包中,将
Chapter 1/sound
文件夹的全部内容复制到D:\VS Projects\Timber\sound
文件夹。 -
现在,在你的网络浏览器中访问
www.1001freefonts.com/komika_poster.font
并下载Komika Poster字体。 -
解压下载的内容,并将
KOMIKAP_.ttf
文件添加到D:\VS Projects\Timber\fonts
文件夹。
让我们来看看这些资源——特别是图形资源——这样我们就可以在我们使用它们在 C++代码中时可视化它们。
探索资源
图形资源构成了场景的各个部分,也就是我们的 Timber!!!游戏。如果你查看图形资源,应该很清楚它们将在我们的游戏中用于何处:
声音文件都是.wav
格式。这些文件包含我们在游戏中的某些事件中要播放的声音效果。它们都是使用 BFXR 生成的,如下所示:
-
chop.wav
: 一种类似斧头(复古斧头)砍树的声音 -
death.wav
: 一种类似复古“失败”的声音 -
out_of_time.wav
: 当玩家因时间耗尽而失败时播放的声音,而不是被压扁
我们已经看到了所有资产,包括图形,因此现在我们将简要讨论屏幕的分辨率以及我们如何在上面定位图形。
理解屏幕和内部坐标
在我们继续实际的 C++编码之前,让我们先简单谈谈坐标。我们显示器上看到的所有图像都是由像素组成的。像素是组成我们看到的图像的小小光点。
监视器有许多不同的分辨率,但以一个例子来说,一个相当典型的游戏显示器可能水平有 1,920 个像素,垂直有 1,080 个像素。
像素是从屏幕左上角开始编号的。正如您可以从以下图表中看到的那样,我们的 1,920 x 1,080 示例在水平(x)轴上从 0 编号到 1,919,在垂直(y)轴上从 0 编号到 1,079:
因此,可以通过 x 和 y 坐标来识别一个特定的屏幕位置。我们通过将游戏对象如背景、角色、子弹和文本绘制到屏幕上的特定位置来创建我们的游戏。这些位置由像素坐标来识别。请看以下假设的例子,说明我们如何绘制到屏幕的大约中心坐标。在一个 1,920 x 1080 的屏幕上,这将是在 960, 540 位置:
除了屏幕坐标外,我们的游戏对象各自还将拥有自己的类似坐标系统。就像屏幕坐标系统一样,它们的内部或局部坐标从左上角的 0,0 开始。
在上一张图像中,我们可以看到角色的 0,0 坐标被绘制在屏幕的 960, 540 位置。
一个二维的可视游戏对象,如角色或僵尸,被称为精灵。精灵通常由图像文件组成。所有精灵都有一个所谓的原点。
如果我们在屏幕上的特定位置绘制一个精灵,那么原点将位于这个特定位置。精灵的 0,0 坐标是其原点。以下图像演示了这一点:
因此,在显示绘制到屏幕上的角色的图像中,尽管我们在中心位置(960, 540)绘制了图像,但它看起来稍微偏右和向下一点。
了解这一点很重要,因为它将帮助我们理解我们用来绘制所有图形的坐标。
重要提示
注意,在现实世界中,游戏玩家有各种各样的屏幕分辨率,我们的游戏需要尽可能多地与它们兼容。在第三个项目中,我们将看到如何使我们的游戏动态适应几乎任何分辨率。在这个第一个项目中,我们需要假设屏幕分辨率为 1,920 x 1,080。如果你的屏幕分辨率更高,这将是可以的。如果你的屏幕分辨率低于这个值,请不要担心,因为我为 Timber!!! 游戏的每个章节都提供了一套单独的代码。代码文件几乎完全相同,除了在开头添加和交换几行代码。如果你有低分辨率的屏幕,只需遵循本书中的代码即可,它假设你有一个 1,920 x 1,080 的分辨率。当尝试运行游戏时,你可以从前五章的 low res
文件夹中复制并粘贴相应的代码文件。实际上,一旦从本章添加了额外的行,所有其余的代码都将完全相同,无论你的屏幕分辨率如何。我提供了每个章节的低分辨率代码,仅作为便利。这些几行代码如何施展魔法(调整屏幕分辨率)将在第三个项目中讨论。替代代码将适用于低至 960 x 540 的分辨率,因此应该几乎在任何 PC 或笔记本电脑上都能正常工作。
现在,我们可以编写我们的第一段 C++ 代码并看到它的实际效果。
开始编写游戏代码
如果 Visual Studio 还未打开,请打开它。通过在主 Visual Studio 窗口的最近列表中左键单击它来打开 Timber!!! 项目。
在源文件文件夹下找到 Timber.cpp
文件。
重要提示
.cpp 代表 C++。
删除代码窗口中的全部内容,并添加以下代码,以便你有相同的代码。你可以像使用任何文本编辑器或文字处理器一样这样做;如果你更喜欢,甚至可以复制并粘贴。在你完成编辑后,我们可以讨论它:
// This is where our game starts from
int main()
{
return 0;
}
这个简单的 C++ 程序是一个很好的起点。让我们逐行分析它。
使用注释使代码更清晰
代码的第一行如下:
// This is where our game starts from
任何以两个正斜杠(//
)开头的代码行都是注释,并且会被编译器忽略。因此,这一行代码没有任何作用。它用于留下任何我们可能在稍后回到代码时可能觉得有用的信息。注释在行尾结束,所以下一行上的内容不属于注释。还有一种注释类型称为多行或C 风格注释,可以用来留下超过单行的注释。我们将在本章后面看到一些例子。在整个书中,我将留下数百条注释,以帮助添加上下文并进一步解释代码。
主函数
我们在代码中看到的下一行如下:
int main()
int
是一个整数或整个数。记住这个想法,我们将在一分钟内回到它。
main()
部分是后续代码部分的名称。代码部分由开括号({
)和下一个闭括号(}
)之间的内容标记。
因此,这些花括号{...}
之间的所有内容都是main
的一部分。我们称这样的代码部分为函数。
每个 C++程序都有一个main
函数,它是main
函数所在的地方。无论我们编写什么代码,我们的游戏都将从main
函数开括号内的第一行代码开始执行。
现在,不要担心函数名后面的奇怪括号。我们将在第四章**,循环、数组、开关、枚举和函数 – 实现游戏机制中进一步讨论它们,当我们从全新的、更有趣的角度看待函数时。
让我们仔细看看main
函数中的一行代码。
呈现和语法
再次查看我们的main
函数的全部内容:
int main()
{
return 0;
}
我们可以看到,在Main
内部,只有一行代码,return 0;
。在我们继续了解这一行代码的作用之前,让我们看看它是如何呈现的。这很有用,因为它可以帮助我们准备编写易于阅读且与其他代码部分区分开的代码。
首先,注意return 0;
缩进了一个制表符到右边。这清楚地表明它是在main
函数内部的。随着我们的代码长度增加,我们会看到缩进代码和留出空白将对于保持可读性至关重要。
接下来,注意行尾的标点符号。分号(;
)告诉编译器这是指令的结束,并且其后的任何内容都是一个新的指令。我们称由分号终止的指令为statement
。
注意,编译器并不关心你在分号和下一个语句之间是否留出新行,甚至是否留有空格。然而,每个语句不开始新行会导致代码难以阅读,而完全遗漏分号则会导致语法错误,游戏将无法编译或运行。
代码部分一起,通常通过其缩进与其他部分区分,被称为块。
现在你已经熟悉了main
函数的概念,学会了通过缩进保持代码整洁,并在每个语句的末尾放置分号,我们可以继续了解return 0;
语句实际上做什么。
从函数返回值
实际上,return 0;
在我们的游戏中几乎没有任何作用。然而,这个概念是非常重要的。当我们使用return
关键字时,无论是单独使用还是后面跟着一个值,它都是程序执行跳转/移动回最初启动函数的代码的指令。
通常,启动函数的代码将是我们代码中其他地方的另一个函数。然而,在这种情况下,是操作系统启动了main
函数。因此,当执行return 0;
时,main
函数退出,整个程序结束。
由于我们在return
关键字后面有一个0
,这个值也会被发送到操作系统。我们可以将 0 的值更改为其他值,然后这个值将被发送回去。
我们说,启动函数的代码调用函数,而函数返回值。
你现在不需要完全掌握所有这些函数信息。这里只是介绍它。在继续之前,我还会提到关于函数的最后一件事。还记得int main()
中的int
吗?这告诉编译器从main
返回的值的类型必须是int
(整数/整个数字)。我们可以返回任何符合int
的值;也许 0,1,999,6,358 等等。如果我们尝试返回不是int
的值,比如 12.76,那么代码将无法编译,游戏将无法运行。
函数可以返回一大类不同的类型,包括我们为自己发明的类型!然而,这种类型必须以我们刚才看到的方式让编译器知道。
这一点关于函数的背景信息将使我们在前进的过程中更加顺利。
运行游戏
你甚至可以在这一点上运行游戏。通过点击 Visual Studio 快速启动栏中的本地 Windows 调试器按钮来这样做。或者,你可以使用F5快捷键:
你将只看到一个黑色屏幕。如果黑色屏幕没有自动关闭,你可以按任意键来关闭它。这个窗口是 C++控制台,我们可以用它来调试我们的游戏。我们现在不需要这样做。正在发生的事情是,我们的程序正在启动,从main
函数的第一行开始执行,即return 0;
,然后立即返回到操作系统。
我们现在已经有了可能的最简单的程序,已经编码并运行。接下来,我们将添加一些代码来打开一个游戏最终会出现在其中的窗口。
使用 SFML 打开窗口
现在,让我们添加一些更多的代码。接下来的代码将使用 SFML 打开一个窗口,Timber!!!最终将在其中运行。窗口将宽 1,920 像素,高 1,080 像素,并将全屏(没有边框或标题)。
将这里突出显示的新代码输入到现有代码中,然后我们将检查它。当你输入(或复制粘贴)时,尽量弄清楚正在发生的事情:
// Include important libraries here
#include <SFML/Graphics.hpp>
// Make code easier to type with “using namespace”
using namespace sf;
// This is where our game starts from
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, “Timber!!!”, Style::Fullscreen);
return 0;
}
#包括 SFML 功能
我们在新代码中首先注意到的是#include
指令。
#include
.hpp
文件扩展名意味着它是一个头文件。
因此,#include <SFML/Graphics.hpp>
告诉预处理器包含名为SFML
的文件夹中Graphics.hpp
文件的內容。这正是我们在设置项目时创建的文件夹。
这一行添加了上述文件中的代码,它为我们提供了访问 SFML 一些功能的方法。它究竟是如何实现这一点的,将在我们开始编写自己的独立代码文件并使用#include
来使用它们时变得更加清晰。
在本书中,我们将包含的主要文件是提供我们访问所有酷炫游戏编程功能的 SFML 头文件。我们还将使用#include
来访问C++标准库头文件。这些头文件为我们提供了访问 C++语言本身核心功能的方法。
目前重要的是,我们有一系列由 SFML 提供的新功能可供使用,如果我们添加这一行代码。
下一个新行是using namespace sf;
。我们很快就会回到这一行的作用。
面向对象编程(OOP)、类和对象
我们将在本书的后续内容中全面讨论面向对象编程、类和对象。以下是一个简要介绍,以便我们理解正在发生的事情。
我们已经知道 OOP 代表面向对象编程。OOP 是一种编程范式,即一种编码方式。OOP 在编程界被普遍接受,几乎在所有语言中,都被认为是最好的,如果不是唯一的专业编写代码的方式。
面向对象编程引入了许多编程概念,但它们的基础是类和对象。当我们编写代码时,尽可能希望编写可重用、可维护和安全的代码。我们通过将代码结构化为类来实现这一点。我们将在第六章**,面向对象编程 – 开始 Pong 游戏中学习如何做到这一点。
目前我们只需要了解关于类的基本知识,那就是一旦我们编写了我们的类,我们不仅仅是将这段代码作为游戏的一部分来执行;相反,我们是从类中创建可用的对象。
例如,如果我们想要 100 个僵尸Zombie
,然后从单个类中创建尽可能多的僵尸对象。每一个僵尸对象都会有相同的功能和内部数据类型,但每一个僵尸对象都是一个独立且独特的实体。
为了进一步阐述假设的僵尸例子,但不展示Zombie
类的任何代码,我们可以基于Zombie
类创建一个新的对象,如下所示:
Zombie z1;
z1
对象现在是一个完全编码并可以运行的Zombie
对象。然后我们可以这样做:
Zombie z2;
Zombie z3;
Zombie z4;
Zombie z5;
现在我们有五个独立的Zombie
类,使我们能够使用我们的Zombie
对象,可能如下所示:
z1.attack(player);
z2.growl();
z3.headExplode();
重要提示
再次注意,所有这些僵尸代码目前都是假设性的。不要将此代码输入 Visual Studio,它只会产生一堆错误。
我们将设计我们的类,以便以最合适的方式使用数据和行为来满足游戏目标。例如,我们可以设计我们的类,以便在创建每个僵尸对象时为其数据分配值。
假设我们需要在创建每个僵尸时分配一个独特的名称和每秒米数。仔细编写 Zombie
类可以让我们写出如下代码:
// Dave was a 100 metre Olympic champion before infection
// He moves at 10 metres per second
Zombie z1(“Dave”, 10);
// Gill had both of her legs eaten before she was infected
// She drags along at .01 metres per second
Zombie z2(“Gill”, .01);
重点是,类几乎是无限灵活的,一旦我们编写了类,我们就可以通过创建它们的对象/实例来使用它们。我们将通过类和我们从中创建的对象来利用 SFML 的力量。是的,我们还将编写自己的类,包括一个 Zombie
类。
让我们回到我们刚刚编写的真实代码。
使用命名空间 sf
在我们继续深入了解 VideoMode
和 RenderWindow
之前,你可能已经猜到了,它们是 SFML 提供的类,我们将学习 using namespace sf;
这行代码的作用。
当我们创建一个类时,我们是在 VideoMode
类中创建的。在像 Windows 这样的环境中,有人可能已经编写了一个名为 VideoMode
的类。通过使用命名空间,我们和 SFML 程序员可以确保类的名称永远不会冲突。
使用 VideoMode
类的完整方式如下:
sf::VideoMode...
using namespace sf;
允许我们在代码的任何地方省略 sf::
前缀。如果没有它,仅在这个简单的游戏中就会有超过 100 个 sf::
实例。它还使我们的代码更易于阅读,更简洁。
SFML VideoMode 和 RenderWindow
在 main
函数内部,我们现在有了两个新的注释和两行实际的代码。第一行实际代码如下:
VideoMode vm(1920, 1080);
这段代码创建了一个名为 vm
的对象,它来自名为 VideoMode
的类,并设置了两个内部值 1920
和 1080
。这些值代表了玩家的屏幕分辨率。
下一个新行代码如下:
RenderWindow window(vm, “Timber!!!”, Style::Fullscreen);
在上一行代码中,我们创建了一个名为 window
的新对象,它是由 SFML 提供的名为 RenderWindow
的类。此外,我们在窗口对象内部设置了一些值。
首先,vm
对象用于初始化 window
的一部分。起初,这可能会让人感到困惑。然而,请记住,一个类可以像其创建者想要的那样多样化、灵活。是的,一些类可以包含其他类的其他实例。
小贴士
在这个阶段,你不需要完全理解它是如何工作的,只要你能理解这个概念即可。我们编写一个类,然后从该类中创建可用的对象——有点像建筑师可能会绘制蓝图。你当然不能把所有的家具、孩子和狗都搬到蓝图里,但你可以从蓝图建造房子(或许多房子)。在这个类比中,类就像蓝图,对象就像房子。
接下来,我们使用 “Timber!!!”
值给窗口命名。然后,我们使用预定义的 Style::FullScreen
值使我们的 window
对象全屏。
小贴士
Style::FullScreen
是在 SFML 中定义的一个值。它很有用,因为我们不需要记住内部代码用来表示全屏的整数。这种值的编码术语是 常量
。常量和它们的 C++ 亲戚 变量 将在下一章中介绍。
让我们看看我们的窗口对象在实际操作中的表现。
运行游戏
你现在可以再次运行游戏。你会看到一个更大的黑色屏幕闪烁然后消失。这是我们刚刚编写的 1,920 x 1,080 全屏窗口。不幸的是,目前仍在发生的事情是,我们的程序从 main
的第一行开始执行,创建了一个酷炫的新游戏窗口,然后到达 return 0;
并立即退回到操作系统。
接下来,我们将添加一些代码,这些代码将构成本书中每个游戏的基本结构。这被称为游戏循环。
主游戏循环
我们需要一种方法,让程序保持运行,直到玩家想要退出。同时,我们应该在继续使用 Timber!!! 的过程中,清楚地标记出代码的不同部分。此外,如果我们想阻止游戏退出,我们最好提供一个让玩家在准备好时退出的方法;否则,游戏将永远继续下去!
将以下高亮显示的代码添加到现有代码中,然后我们将对其进行审查并讨论:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, “Timber!!!”, Style::Fullscreen);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
/*
****************************************
Update the scene
****************************************
*/
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last frame
window.clear();
// Draw our game scene here
// Show everything we just drew
window.display();
}
return 0;
}
当循环
在新代码中,我们首先看到的是以下内容:
while (window.isOpen())
{
在新代码中,我们最后看到的是一个闭合的 }
。我们创建的 {
和闭合的 }
括号将不断执行,可能永远如此。
仔细观察 while
循环的括号 (...)
之间,如下所示:
while (window.isOpen())
这段代码的完整解释将留待我们在 第四章 中讨论循环和条件时再进行,即“循环、数组、开关、枚举和函数 – 实现游戏机制”。目前重要的是,当 window
对象设置为关闭状态时,代码的执行将跳出 while
循环并继续执行下一个语句。具体如何关闭窗口将在稍后介绍。
下一个语句当然是 return 0;
,这标志着我们游戏的结束。
我们现在知道我们的while
循环将快速地循环执行其中的代码,直到我们的窗口对象被设置为关闭。
C 风格代码注释
在while
循环内部,我们可以看到乍一看可能有点像 ASCII 艺术的代码:
/*
****************************************
Handle the player’s input
****************************************
*/
重要提示
ASCII 艺术是一种小众但有趣的方式,可以用计算机文本创建图像。您可以在这里了解更多信息:en.wikipedia.org/wiki/ASCII_art
。
之前的代码只是另一种类型的注释。这种类型的注释被称为 C 风格注释。注释从(/*
)开始,以(*/
)结束。其中间的内容只是信息,不会被编译。我使用了这种稍微详细一点的文本,以确保我们清楚地知道代码文件中的每一部分将要做什么。当然,你现在可以推断出接下来的任何代码都将与处理玩家的输入相关。
跳过几行代码,你会看到我们还有另一个 C 风格注释,宣布在那个代码部分,我们将更新场景。
如果你跳转到下一个 C 风格注释,就会清楚我们将在哪里绘制所有图形。
输入、更新、绘制、重复
虽然这个第一个项目使用了最简单的游戏循环版本,但每个游戏都需要代码中的这些阶段。让我们回顾一下步骤:
-
获取玩家的输入(如果有)。
-
根据人工智能、物理或玩家的输入等更新场景。
-
绘制当前场景。
-
以足够快的速度重复这些步骤,以创建一个平滑的动画游戏世界。
现在,让我们看看在游戏循环中实际执行某些操作的代码。
检测按键
首先,在可以通过带有Handle the player’s input
文本的注释识别的部分,我们有以下代码:
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
这段代码检查是否正在按下Esc键。如果是的话,高亮显示的代码使用window
对象来关闭自身。现在,下一次while
循环开始时,它会看到window
对象已经关闭,并跳转到while
循环结束花括号之后的代码,游戏将退出。我们将在第二章**中更全面地讨论if
语句,变量、运算符和决策 – 动画精灵。
清除和绘制场景
目前,在更新场景
部分没有代码,所以让我们继续到绘制场景
部分。
我们将首先使用以下代码擦除上一帧的动画:
window.clear();
我们现在要做的是绘制游戏中的每一个对象。然而,我们没有任何游戏对象。
下一行代码如下:
window.display();
当我们绘制所有游戏对象时,我们实际上是在一个隐藏的表面上绘制,这个表面准备被显示。window.display()
代码会将之前显示的表面翻转到新更新的(之前隐藏的)一个。这样,玩家将永远不会看到绘制过程,因为表面已经添加了所有精灵。这也保证了场景在翻转之前是完整的。这防止了名为 撕裂 的图形错误。这个过程被称为 双缓冲。
还要注意,所有这些绘制和清除功能都是使用我们的 window
对象执行的,该对象是从 SFML 的 RenderWindow
类创建的。
运行游戏
运行游戏,你将得到一个空白的全屏窗口,直到你按下 Esc 键。
这是个不错的进展。在这个阶段,我们有一个正在执行的程序,它打开一个窗口并循环等待玩家按下 Esc 键以退出。现在,我们能够继续绘制游戏的背景图像。
绘制游戏背景
现在,我们将看到我们游戏中的图形。我们需要做的是创建一个精灵。我们将首先创建游戏背景。然后我们可以在清除窗口和显示/翻转它之间绘制它。
使用纹理准备精灵
SFML 的 RenderWindow
类允许我们创建我们的 window
对象,它基本上负责了游戏窗口所需的所有功能。
我们现在将查看另外两个负责将精灵绘制到屏幕上的 SFML 类。其中一个类,可能不出所料,被称为 Sprite
。另一个类被称为 Texture
。纹理是存储在内存中的图形,在 图形处理单元(GPU)上。
由 Sprite
类创建的对象需要一个由 Texture
类创建的对象来显示自身作为图像。添加以下突出显示的代码。试着弄清楚发生了什么。然后,我们将逐行分析它:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, “Timber!!!”, Style::Fullscreen);
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
// Load a graphic into the texture
textureBackground.loadFromFile(“graphics/background.png”);
// Create a sprite
Sprite spriteBackground;
// Attach the texture to the sprite
spriteBackground.setTexture(textureBackground);
// Set the spriteBackground to cover the screen
spriteBackground.setPosition(0,0);
while (window.isOpen())
{
首先,我们创建一个名为 textureBackground
的对象,该对象来自 SFML 的 Texture
类:
Texture textureBackground;
一旦完成,我们可以使用 textureBackground
对象将我们的 graphics
文件夹中的图形加载到 textureBackground
中,如下所示:
textureBackground.loadFromFile(“graphics/background.png”);
小贴士
我们只需要指定 graphics/background
作为路径,因为它是相对于我们创建文件夹并添加图像的 Visual Studio 工作目录 的相对路径。
接下来,我们使用以下代码创建一个名为 spriteBackground
的对象,该对象来自 SFML 的 Sprite
类:
Sprite spriteBackground;
然后,我们可以将 Texture
对象(backgroundTexture
)与 Sprite
对象(backgroundSprite
)关联起来,如下所示:
spriteBackground.setTexture(textureBackground);
最后,我们可以在 window
对象中将 spriteBackground
对象定位在 0,0
坐标上:
spriteBackground.setPosition(0,0);
由于 graphics
文件夹中的 background.png
图形宽度为 1,920 像素,高度为 1,080 像素,它将整齐地填充整个屏幕。请注意,此前的代码行实际上并没有显示精灵。它只是设置了其位置,以便在显示时使用。
backgroundSprite
对象现在可以用来显示背景图形。当然,你几乎肯定想知道为什么我们不得不以这种方式做事。原因是由于显卡和 OpenGL 的工作方式。
纹理占用图形内存,这种内存是有限的资源。此外,将图形加载到 GPU 内存中的过程非常缓慢——并不那么慢以至于你可以看到它发生,或者你会在它发生时明显感觉到你的电脑变慢,但足够慢以至于你不能在游戏循环的每一帧都这样做。因此,将实际的纹理(textureBackground
)与我们在游戏循环中将要操作的任何代码解耦是有用的。
当我们开始移动我们的图形时,我们将使用精灵来这样做。任何由Texture
类创建的对象都将愉快地坐在 GPU 上,等待相关的Sprite
对象告诉它在哪里显示自己。在未来的项目中,我们还将使用相同的Texture
对象与多个不同的Sprite
对象一起使用,这使 GPU 内存的使用更加高效。
总结来说,我们可以陈述以下内容:
-
纹理加载到 GPU 上非常慢。
-
纹理一旦在 GPU 上,访问速度非常快。
-
我们将一个
Sprite
对象与一个纹理关联起来。 -
我们操作
Sprite
对象的位置和方向(通常在更新场景
部分)。
我们绘制Sprite
对象,它反过来显示与之关联的Texture
对象(通常在绘制场景
部分)。因此,我们现在需要做的就是使用我们的双缓冲系统,这是由我们的window
对象提供的,来绘制我们的新Sprite
对象(spriteBackground
),然后我们应该能看到我们的游戏正在运行。
背景精灵的双缓冲
最后,我们需要在游戏循环的适当位置绘制那个精灵及其关联的纹理。
小贴士
注意,当我展示来自同一块的代码时,我不会添加缩进,因为这会减少书中文本的换行实例。缩进是隐含的。请查看下载包中的代码文件以查看缩进的完整使用。
添加以下突出显示的代码:
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last run frame
window.clear();
// Draw our game scene here
window.draw(spriteBackground);
// Show everything we just drew
window.display();
新的代码行只是使用window
对象在清除显示和显示新绘制的场景之间绘制spriteBackground
对象。
我们现在知道什么是精灵,我们可以将它与纹理关联起来,然后在屏幕上定位它,最后绘制它。游戏现在可以再次运行,以便我们可以看到这段代码的结果。
运行游戏
如果我们现在运行程序,我们将看到我们有一个真正的游戏正在进行的第一迹象:
它目前不可能在 Steam 上获得年度独立游戏奖,但至少我们正在这条路上!
让我们看看在这一章中以及我们继续阅读这本书的过程中可能会出现的一些问题。
处理错误
在你制作的每个项目中,都可能会遇到问题和错误。这是肯定的!问题越困难,当你解决它时就越令人满意。经过数小时的挣扎后,一个新游戏功能终于焕发生机,这可能会引起真正的兴奋。如果没有这种挣扎,它似乎就变得不那么值得了。
在这本书的某个阶段,你可能会遇到一些挑战。保持冷静,对自己有信心,你将能够克服它,然后开始工作。
记住,无论你的问题是什么,你很可能不是世界上第一个遇到这种相同问题的人。想一个简洁的句子来描述你的问题或错误,然后将其输入到谷歌搜索中。你会惊讶地发现,其他人解决你的问题的速度有多快、有多精确,以及有多频繁。
话虽如此,这里有一些提示(有意为之;参见第十章**,指针、标准模板库和纹理管理),以防你在使第一章工作时有困难。
配置错误
本章中问题最可能的原因是配置错误。正如你可能在设置 Visual Studio、SFML 和项目本身的过程中注意到的那样,有很多文件名、文件夹和设置需要恰到好处。仅仅一个错误的设置就可能导致一系列错误,其文本并没有清楚地说明具体出了什么问题。
如果你无法让空项目在黑色屏幕上运行,重新开始可能更容易。确保所有文件名和文件夹名都适合你的特定设置,然后运行代码的最简单部分。这是屏幕闪烁黑色然后关闭的部分。如果你能到达这个阶段,那么配置可能不是问题。
编译错误
编译错误可能是我们接下来最常遇到的错误。检查你的代码是否与我的一致,特别是行尾的分号和类名、对象名的大小写微妙变化。如果所有其他方法都失败了,请打开下载包中的代码文件,并复制粘贴进去。虽然总是有可能代码中的打字错误被收录到这本书中,但代码文件是从实际的工作项目中制作的——它们肯定能工作!
链接错误
链接错误最可能是由于缺少 SFML .dll
文件造成的。你是否已经将它们全部复制到了项目文件夹中?
错误
错误发生在你的代码按预期工作,但并非如你所愿时。调试实际上可以很有趣。你解决的错误越多,你的游戏越好,你一天的工作就越令人满意。解决错误的技巧是尽早找到它们!为此,我建议每次实现新功能时都运行并玩你的游戏。你发现错误越早,越有可能在脑海中清晰地记住原因。在这本书中,我们将运行代码以查看每个可能阶段的输出结果。
摘要
这章内容相当具有挑战性,也许作为第一章来说有点苛刻。确实,配置 IDE 以使用 C++库可能有点棘手且耗时。此外,类和对象的概念对于编程新手来说通常被认为有点棘手。
然而,现在我们已经到了这个阶段,我们可以完全专注于 C++、SFML 和游戏。随着我们继续阅读这本书,我们将学习越来越多的 C++,并实现越来越有趣的游戏特性。在这个过程中,我们将进一步探讨函数、类和对象等概念,以帮助大家更好地理解它们。
我们在本章中取得了许多成果,包括概述了一个带有主函数的基本 C++程序,构建了一个简单的游戏循环,该循环监听玩家输入并在屏幕上绘制精灵(及其关联的纹理)。
在下一章,我们将学习所有我们需要用来绘制更多精灵并使它们动画化的 C++知识。
常见问题解答
这里有一些可能出现在你脑海中的问题:
Q) 我发现到目前为止的内容有点难以理解。我是不是适合编程?
A) 设置开发环境并理解面向对象编程这个概念可能是你在本书中要做的最困难的事情。只要你的游戏正在运行(绘制背景),你就可以继续进行下一章的学习。
Q) 所有关于面向对象编程、类和对象的讨论太多了,有点破坏了整个学习体验。
A) 别担心。我们会不断地回到面向对象编程(OOP)、类和对象。在第六章,“面向对象编程 – 开始打乒乓球游戏”,我们将真正开始深入理解整个面向对象编程的概念。你现在需要理解的是,SFML 已经编写了许多有用的类,我们可以通过从这些类中创建可用的对象来使用这段代码。
Q) 我真的不太懂这个函数的东西。
A) 没关系;我们将会不断地回到这个话题,并且会更深入地学习函数。你只需要知道,当一个函数被调用时,它的代码会被执行,当它完成(到达return
语句)时,程序会跳回到调用它的代码处。
第三章:第二章:变量、运算符和决策 – 动画精灵
在本章中,我们将在屏幕上进行更多的绘制,为了实现这一点,我们需要了解一些 C++的基础知识。我们将学习如何使用变量来记住和操作值,并开始向游戏中添加更多图形。随着本章的进展,我们将了解如何操作这些值来动画图形。这些值被称为变量。
这里是您将获得的内容:
-
学习有关 C++变量的所有内容
-
看看如何操作存储在变量中的值
-
添加一个静态树木图形,供玩家砍伐
-
绘制和动画一只蜜蜂和三个云朵
C++变量
变量是我们 C++游戏存储和操作值/数据的方式。如果我们想知道玩家有多少生命值,我们需要一个变量。也许你想知道当前波次中剩余了多少僵尸。那也是一个变量。如果你需要记住获得高分玩家的名字,你猜对了——我们需要一个变量来存储那个信息。游戏结束了吗,还是仍在进行中?是的,那也是一个变量。
变量是 PC 内存中位置的命名标识符。PC 的内存是计算机程序在执行时存储的地方。因此,我们可以将一个变量命名为numberOfZombies
,这个变量可以指向内存中的一个位置,该位置存储一个值,表示当前波次中剩余的僵尸数量。
计算机系统在内存中定位的方式很复杂。编程语言使用变量为我们提供了一种人性化的方式来管理该内存中的数据。
我们刚才提到的关于变量的少量信息暗示了必须存在不同类型的变量。
变量类型
C++变量类型繁多(请参阅几页后的关于变量的下一个提示)。很容易想象可以花整整一章来讨论它们。以下是一个表格,列出了本书中最常用的变量类型。然后,在下一节中,我们将探讨如何使用这些变量类型:
编译器必须被告知变量的类型,以便它可以为它分配正确的内存量。对于您使用的每个变量,使用最佳和最合适的类型是一种良好的实践。然而,在实践中,你通常会通过提升变量来解决问题。也许你需要一个只有五个有效数字的浮点数?如果你将其存储为double
,编译器不会抱怨。然而,如果你试图将float
或double
存储在int
中,它将变成int
。随着我们通过这本书的进展,我会清楚地说明在每种情况下使用最佳变量类型是什么,我们甚至会看到一些我们故意在变量类型之间转换/转换的例子。
在前面的表中,有几个值得注意的额外细节包括所有 float
值旁边的 f
后缀。这个 f
后缀告诉编译器该值是 float
类型,而不是 double
。没有 f
前缀的浮点值默认是 double
。关于这一点,请参阅下一节关于变量的提示。
如我们之前提到的,还有许多其他类型。如果你想了解更多关于类型的信息,请参阅下一节关于变量的提示。
用户定义的类型
用户定义的类型比我们刚刚看到的类型要高级得多。当我们谈论 C++ 中的用户定义类型时,我们通常指的是类。我们在上一章中简要介绍了类及其相关对象。我们将代码写入单独的文件,有时是两个文件。然后我们能够声明、初始化和使用它们。我们将如何定义/创建我们自己的类型留到 第六章**,面向对象编程 – 开始乒乓球游戏。
声明和初始化变量
到目前为止,我们知道变量用于存储我们的游戏需要的数据/值,以便工作。例如,一个变量可以代表玩家拥有的生命值或玩家的名字。我们还知道,这些变量可以表示多种不同类型的值,例如 int
、float
、bool
等。当然,我们还没有看到的是我们如何实际使用变量。
创建和准备新变量有两个阶段。这些阶段被称为 声明 和 初始化。
声明变量
我们可以在 C++ 中这样声明变量:
// What is the player's score?
int playerScore;
// What is the player's first initial
char playerInitial;
// What is the value of pi
float valuePi;
// Is the player alive or dead?
bool isAlive;
一旦我们编写了声明变量的代码,它就存在了,并准备好在我们的代码中使用。然而,我们通常会想要给变量赋予一个合适的值,这就是初始化的作用所在。
初始化变量
现在我们已经用有意义的名字声明了变量,我们可以用适当的值初始化这些变量,如下所示:
playerScore = 0;
playerInitial = 'J';
valuePi = 3.141f;
isAlive = true;
到目前为止,变量已经存在并持有特定的值。很快,我们将看到如何更改、测试和响应这些值。接下来,我们将看到我们可以将声明和初始化合并为一步。
一步声明和初始化
当我们觉得合适时,我们可以将声明和初始化步骤合并为一步。有时,我们知道变量必须以什么值开始程序,在这种情况下,一步声明和初始化是合适的。通常情况下,我们不知道,我们首先声明变量,然后在程序的后面初始化它,如下所示:
int playerScore = 0;
char playerInitial = 'J';
float valuePi = 3.141f;
bool isAlive = true;
变量提示
如承诺的那样,这里有一些关于变量的技巧。如果你想查看 C++类型的完整列表,请查看这个网页:www.tutorialspoint.com/cplusplus/cpp_data_types.htm
。如果你想对float
、double
和f
后缀进行更深入的讨论,请阅读这个:www.cplusplus.com/forum/beginner/24483/
。最后,如果你想了解 ASCII 字符码的来龙去脉,这里有一些更多信息:www.cplusplus.com/doc/ascii/
。请注意,这些链接是为那些好奇心旺盛的读者准备的,我们已经讨论了足够的内容以便继续前进。
常量
有时候,我们需要确保一个值永远不能被改变。为了实现这一点,我们可以声明并初始化一个const
关键字:
const float PI = 3.141f;
const int PLANETS_IN_SOLAR_SYSTEM = 8;
const int NUMBER_OF_ENEMIES = 2000;
声明常量的惯例是全部大写。前面常量的值永远不能改变。我们将在第四章“循环、数组、开关、枚举和函数 – 实现游戏机制”中看到一些常量的实际应用。
声明和初始化用户定义的类型
我们已经看到了如何声明和初始化一些 SFML 定义的类型的例子。正是因为我们可以如此灵活地创建/定义这些类型(类),所以它们的声明和初始化方式也是多种多样的。这里有一些关于从上一章声明和初始化用户定义类型的提醒。
创建一个名为vm
的VideoMode
类型的对象,并用两个int
值1920
和1080
进行初始化:
// Create a video mode object
VideoMode vm(1920, 1080);
创建一个名为textureBackground
的Texture
类型的对象,但不要进行任何初始化:
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
注意,即使我们没有建议用于初始化textureBackground
的具体值,也可能(实际上,可能性非常大)在内部进行一些变量的设置。一个对象是否需要/有在这一点上提供初始化值的选项,完全取决于类的编码方式,几乎是无限灵活的。这进一步表明,当我们开始编写自己的类时,将有一些复杂性。幸运的是,这也意味着我们将拥有显著的能力来设计我们的类型/类,以满足我们制作游戏的需求!将这种巨大的灵活性添加到 SFML 设计类的力量中,我们游戏的可能性几乎是无限的。
在本章中,我们还将看到 SFML 提供的更多用户创建的类型/类,在整个书中还有更多。
我们已经看到,变量是计算机内存中的一个命名位置,变量可以是简单的整数,也可以是更强大的对象。既然我们知道我们可以初始化这些变量,我们将看看我们如何可以操作它们所持有的值。
变量的操作
到目前为止,我们确切地知道变量是什么,它们可以有哪些主要类型,以及如何声明和初始化它们。然而,我们仍然不能做很多。我们需要操作我们的变量;给它们加值;减去它们的值;以及乘以、除以和测试它们。
首先,我们将处理如何操作它们,然后我们将看看如何以及为什么我们要测试它们。
C++算术和赋值运算符
为了操作变量,C++提供了一系列的算术运算符和赋值运算符。幸运的是,大多数算术和赋值运算符的使用非常直观,那些不太直观的也很容易解释。为了让我们开始,让我们先看看算术运算符的表格,然后是赋值运算符的表格,这些表格我们将在这本书的整个过程中经常使用:
现在来看看赋值运算符:
重要提示
技术上,除了=
、--
和++
之外的所有这些运算符都被称为复合赋值运算符,因为它们由多个运算符组成。
现在我们已经看到了一系列的算术和赋值运算符,我们可以实际看看我们如何通过结合运算符、变量和值来形成表达式来操作我们的变量。
使用表达式完成任务
表达式是结合变量、运算符和值的结果。使用表达式,我们可以得到一个结果。此外,正如我们很快就会看到的,我们可以在测试中使用表达式。这些测试可以用来决定我们的代码下一步应该做什么。首先,让我们看看我们可能在游戏代码中看到的简单表达式。这里是一个简单表达式的例子:
// Player gets a new high score
hiScore = score;
在前面的代码中,score
变量所持有的值被用来改变hiScore
变量的值。现在这两个变量持有相同的值,但请注意,它们仍然是独立的、不同的变量(内存中的位置)。当玩家打破高分时,这可能是我们需要的。这里还有一个例子:
// Set the score to 100
score = 100;
让我们看看加法运算符,它与赋值运算符结合使用:
// Add to the score when an alien is shot
score = aliensShot + wavesCleared;
在前面的代码中,aliensShot
和wavesCleared
所持有的值通过加法运算符相加,然后将加法的结果赋值给score
变量。现在,让我们看看以下代码:
// Add 100 to whatever the score currently is
score = score + 100;
注意,在运算符两侧使用相同的变量是完全可接受的。在前面代码中,100 被加到score
变量所持有的值上,然后这个新值被重新赋值给score
。
看看减法运算符与赋值运算符的结合。以下代码从减法运算符左侧的值中减去右侧的值。它通常与赋值运算符结合使用,可能如下所示:
// Uh oh lost a life
lives = lives - 1;
它也可以这样使用:
// How many aliens left at end of game
aliensRemaining = aliensTotal - aliensDestroyed;
接下来,我们将看到我们如何可能使用除法操作符。以下代码将左边的数字除以右边的数字。同样,它通常与赋值操作符一起使用,如下所示:
// Make the remaining hit points lower based on swordLevel
hitPoints = hitPoints / swordLevel;
它也可以这样使用:
// Give something, but not everything, back for recycling a block
recycledValueOfBlock = originalValue / .9f;
显然,在上一个例子中,recycledValueOfBlock
变量需要是float
类型,才能准确存储像那样的计算结果。
也许不出所料,我们可以像这样使用乘法操作符:
// answer is equal to 100, of course
answer = 10 * 10;
它也可以这样使用:
// biggerAnswer = 1000, of course
biggerAnswer = 10 * 10 * 10;
重要提示
作为旁白,你有没有想过 C++是如何得到它的名字的?C++是 C 语言的扩展。它的发明者Bjarne Stroustrup最初称它为“带类的 C”,但这个名字演变了。如果你感兴趣,你可以阅读 C++的故事www.cplusplus.com/info/history/
。
现在,让我们看看增量操作符的实际应用。这是一种巧妙的方法,可以给我们的游戏变量中的一个值加 1。
看看下面的代码:
// Add one to myVariable
myVariable = myVariable + 1;
上述代码给出的结果与以下代码相同:
// Much neater and quicker
myVariable ++;
减量操作符--
,正如你所猜到的,是一种快速从某物中减去 1 的方法,如下所示:
playerHealth = playerHealth -1;
这与以下操作相同:
playerHealth --;
让我们看看几个操作符在实际中的应用,然后我们可以回到构建 Timber!!!游戏。加法、减法、乘法和除法操作符每个都有一个相关的操作符,它将它们的主要功能(加法、减法等)与赋值结合起来。这允许我们在想要执行操作符的主要功能后进行赋值时,使用更简洁的代码。看看下面的四个例子(每个操作符一个):
someVariable = 10;
// Multiply the variable by 10 and put the answer
// back in the variable
someVariable *= 10;
// someVariable now equals 100
// Divide someVariable by 5 put the answer back
// into the variable
someVariable /= 5;
// someVariable now equals 20
// Add 3 to someVariable and put the answer back
// into the variable
someVariable += 3;
// someVariable now equals 23
// Take 25 from someVariable and put the answer back
// into the variable
someVariable -= 25;
// someVariable now equals -2
在前面的四个例子中,我们可以看到*=
、/=
、+=
和-=
操作符可以用来缩短语法,当我们想要使用四个算术操作符之一后跟赋值时。我们将在整本书中这样做很多。
是时候给我们的游戏添加更多精灵了。
添加云、树和嗡嗡的蜜蜂
在本节中,我们将向 Timber!!!游戏添加云、树和嗡嗡的蜜蜂。首先,我们将添加一棵树。这将会很容易。原因是树不会移动。我们将使用我们在上一章中绘制背景时使用的相同程序。蜜蜂和云在它们的起始位置也很容易绘制,但我们需要将我们刚刚学到的关于操作变量的知识与新学的 C++主题结合起来,使它们移动。
准备树
让我们准备好绘制树!添加以下突出显示的代码。注意未突出显示的代码,这是我们已编写的代码。这应该有助于你识别新代码应该在设置背景位置之后、主游戏循环开始之前立即输入。我们将在添加新代码后提供关于新代码中发生的事情的回顾:
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Timber!!!", Style::Fullscreen);
// Create a texture to hold a graphic on the GPU
Texture textureBackground;
// Load a graphic into the texture
textureBackground.loadFromFile("graphics/background.png");
// Create a sprite
Sprite spriteBackground;
// Attach the texture to the sprite
spriteBackground.setTexture(textureBackground);
// Set the spriteBackground to cover the screen
spriteBackground.setPosition(0, 0);
// Make a tree sprite
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
while (window.isOpen())
{
以下五行代码(不包括注释)所做的是:
-
首先,我们创建一个名为
textureTree
的Texture
类型的对象。 -
接下来,我们从
tree.png
图形文件中加载一个图形到纹理中。 -
然后,我们声明一个名为
spriteTree
的Sprite
类型的对象。 -
之后,我们将
textureTree
与spriteTree
关联。每次我们绘制spriteTree
时,它都会显示textureTree
纹理,这是一个整洁的树形图形。 -
最后,我们使用 x 轴上的坐标
810
和 y 轴上的坐标0
来设置树的位置。
树精灵准备就绪,可以绘制,以及树纹理。让我们继续到蜜蜂对象,它以几乎相同的方式处理。
准备蜜蜂
准备蜜蜂精灵的过程与准备树精灵的过程非常相似,但并不完全相同。以下代码与树代码之间的差异很小但很重要。由于蜜蜂需要移动,我们也声明了两个与蜜蜂相关的变量。添加以下突出显示的代码,看看我们如何可能使用 beeActive
和 beeSpeed
变量:
// Make a tree sprite
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
// Prepare the bee
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Is the bee currently moving?
bool beeActive = false;
// How fast can the bee fly
float beeSpeed = 0.0f;
while (window.isOpen())
{
我们以创建背景和树相同的方式创建蜜蜂。我们使用 Texture
、Sprite
并将它们关联起来。请注意,在之前的蜜蜂代码中,有一些我们之前没有见过的代码。有一个用于确定蜜蜂是否活跃的 bool
变量。请记住,bool
变量可以是 true
或 false
。我们目前将 beeActive
初始化为 false
。
接下来,我们声明一个名为 beeSpeed
的新 float
变量。这将存储蜜蜂在屏幕上飞行的像素每秒速度。
很快,我们将看到我们如何使用这两个新变量来移动蜜蜂。在我们这样做之前,让我们以几乎相同的方式设置一些云朵。
准备云朵
添加以下突出显示的代码。研究新代码,并尝试弄清楚它将做什么:
// Prepare the bee
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Is the bee currently moving?
bool beeActive = false;
// How fast can the bee fly
float beeSpeed = 0.0f;
// make 3 cloud sprites from 1 texture
Texture textureCloud;
// Load 1 new texture
textureCloud.loadFromFile("graphics/cloud.png");
// 3 New sprites with the same texture
Sprite spriteCloud1;
Sprite spriteCloud2;
Sprite spriteCloud3;
spriteCloud1.setTexture(textureCloud);
spriteCloud2.setTexture(textureCloud);
spriteCloud3.setTexture(textureCloud);
// Position the clouds on the left of the screen
// at different heights
spriteCloud1.setPosition(0, 0);
spriteCloud2.setPosition(0, 250);
spriteCloud3.setPosition(0, 500);
// Are the clouds currently on screen?
bool cloud1Active = false;
bool cloud2Active = false;
bool cloud3Active = false;
// How fast is each cloud?
float cloud1Speed = 0.0f;
float cloud2Speed = 0.0f;
float cloud3Speed = 0.0f;
while (window.isOpen())
{
我们刚刚添加的代码中唯一可能显得有点奇怪的是,我们只有一个 Texture
类型的对象。对于多个 Sprite
对象共享纹理是完全正常的。一旦 Texture
存储在 GPU 内存中,它就可以非常快地与 Sprite
对象关联起来。只有 loadFromFile
代码中图形的初始加载是一个相对较慢的操作。当然,如果我们想要三个不同形状的云朵,那么我们需要三个纹理。
除了微小的纹理问题外,我们刚刚添加的代码与蜜蜂的代码相比没有什么新意。唯一的区别是,有三个云朵精灵,三个用于确定每个云朵是否活跃的 bool
变量,以及三个用于存储每个云朵速度的 float
变量。
在这个阶段,所有的精灵和变量都已经准备好了。我们现在可以继续绘制它们。
绘制树、蜜蜂和云朵
最后,我们可以在绘图部分添加以下突出显示的代码来将它们全部绘制到屏幕上:
/*
****************************************
Draw the scene
****************************************
*/
// Clear everything from the last run frame
window.clear();
// Draw our game scene here
window.draw(spriteBackground);
// Draw the clouds
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Draw the tree
window.draw(spriteTree);
// Draw the insect
window.draw(spriteBee);
// Show everything we just drew
window.display();
以与绘制背景相同的方式绘制三个云朵、蜜蜂和树木。注意,然而,我们在屏幕上绘制不同对象的顺序。我们必须在背景之后绘制所有图形,否则它们将被覆盖,我们必须在树木之前绘制云朵,否则它们在树木前飘动会显得有些奇怪。蜜蜂在树木前或后看起来都很好。我选择在树木前绘制蜜蜂,这样它就可以试图分散伐木工的注意力,就像一只真正的蜜蜂可能会做的那样。
运行 Timber!!! 并敬畏地凝视那棵树、三个云朵和一只……什么也不做的蜜蜂。它们看起来像是在排队参加比赛;一场蜜蜂必须倒退着走的比赛:
使用我们对运算符的了解,我们可以尝试移动我们刚刚添加的图形,但有一个问题。问题是真正的云朵和蜜蜂以非均匀的方式移动。它们没有固定的速度或位置,这些元素由风速或蜜蜂可能有多急等因素决定。对于普通观察者来说,它们的路径和速度看起来是 随机 的。
随机数
随机数 在游戏中有很多用途——比如确定玩家被发什么牌,或者从敌人健康值中减去一定范围内的伤害。现在我们将学习如何生成随机数以确定蜜蜂和云朵的起始位置和速度。
在 C++ 中生成随机数
为了生成随机数,我们需要使用一些更多的 C++ 函数——确切地说,两个。现在不要向游戏中添加任何代码。让我们看看一些假设代码的语法和所需的步骤。
计算机不能真正地选择随机数。它们只能使用 算法/计算 来选择一个看起来是随机的数字。为了使这个算法不会不断返回相同的值,我们必须 初始化 随机数生成器。种子可以是任何整数,尽管每次需要不同的随机数时,它必须是一个不同的种子。看看以下代码,它初始化了随机数生成器:
// Seed the random number generator with the time
srand((int)time(0));
上一段代码使用 time
函数从 PC 获取时间,即 time(0)
。对 time
函数的调用被包含为要发送给 srand
函数的值。结果是使用当前时间作为种子。
上一段代码看起来稍微复杂一些,因为 (int)
语法看起来有些不寻常。这样做是将 time
返回的值转换为 int
。在这种情况下,这是 srand
函数所必需的。
重要提示
描述从一种类型到另一种类型的转换所使用的术语是 cast。
因此,总结一下,上一行代码执行以下操作:
-
使用
time
获取时间 -
将其转换为
int
-
将此结果值发送到
srand
,以初始化随机数生成器
时间当然总是在变化的。这使得time
函数成为随机数生成器的一个很好的种子方式。然而,想想如果我们多次并且快速地种子随机数生成器,而time
返回相同的值会发生什么。当我们动画化我们的云时,我们将看到并解决这个问题。
在这个阶段,我们可以创建一个随机数,在某个范围内,并将其保存到变量中供以后使用,如下所示:
// Get the random number & save it to a variable called number
int number = (rand() % 100);
注意我们给number
赋值的方式看起来很奇怪。通过使用模运算符(%
)和100
的值,我们是在询问从rand
返回的数字除以 100 后的余数。当你除以 100 时,你作为余数的最大可能值是 99。可能的最小值是 0。因此,之前的代码将生成一个介于 0 到 99(包括 0 和 99)之间的数字。这个知识将对我们生成蜜蜂和云的随机速度和起始位置很有用。
但在我们能够实现我们的随机蜜蜂和云之前,我们需要学习如何在 C++中做出决策。
使用 if 和 else 进行决策
在上一章中,当我们在每一帧检测玩家是否按下了Esc键时,C++的if
运算符的实际应用:
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
到目前为止,我们已经看到我们可以如何使用算术和赋值运算符来创建表达式。现在,我们将看看一些新的运算符。
逻辑运算符
逻辑运算符将帮助我们通过构建可以测试为真或假的值的表达式来做出决策。一开始,这可能会显得选择很有限,不足以满足高级 PC 游戏中可能需要的决策。一旦我们深入研究,我们将看到我们只需要几个逻辑运算符就可以做出所有需要的决策。
这里是一个最有用的逻辑运算符表。看看它们和相关的例子,然后我们将看看我们如何使用它们:
让我们看看 C++的if
和else
关键字,这将使我们能够充分利用这些逻辑运算符。
C++的 if 和 else
让我们把之前的例子变得更具体一些。认识一下 C++的if
运算符和一些运算符,以及一个小故事来展示它们的使用。接下来是一个虚构的军事情况,希望它比之前的例子更具体。
如果他们过桥,就射击他们!
舰长正在死去,并且知道他剩下的下属经验不足,他决定编写一个 C++程序来传达他死后最后的命令。部队必须守住桥梁的一侧,等待增援。
舰长想要确保他的部队理解的第一条命令是:
"如果他们过桥,就射击他们!"
那么,我们如何在 C++中模拟这种情况?我们需要一个bool
变量isComingOverBridge
。以下代码片段假设isComingOverBridge
变量已经被声明并初始化为true
或false
。
我们可以像这样使用if
:
if(isComingOverBridge)
{
// Shoot them
}
如果isComingOverBridge
变量等于true
,则在大括号{...}
内的代码将运行。如果不等于,则程序在if
块之后继续执行,而不运行其中的代码。
射击他们……或者做这个代替
指挥官还希望告诉他的士兵,如果敌人没有越过桥梁,他们应该原地待命。
现在,我们可以引入另一个 C++关键字,如果if
为true
,我们可以使用else
。
例如,如果敌人没有越过桥梁,我们可以编写以下代码来告诉士兵们原地待命:
if(isComingOverBridge)
{
// Shoot them
}
else
{
// Hold position
}
然后,指挥官意识到问题并不像他最初想的那么简单。如果敌军越过桥梁,但人数过多怎么办?他的小队会被围攻并屠杀。因此,他想出了以下代码(这次我们也会使用一些变量):
bool isComingOverBridge;
int enemyTroops;
int friendlyTroops;
// Initialize the previous variables, one way or another
// Now the if
if(isComingOverBridge && friendlyTroops > enemyTroops)
{
// shoot them
}
else if(isComingOverBridge && friendlyTroops < enemyTroops)
{
// blow the bridge
}
else
{
// Hold position
}
前面的代码有三个可能的执行路径。首先,如果敌军越过桥梁且友军数量更多:
if(isComingOverBridge && friendlyTroops > enemyTroops)
第二种情况是敌军越过桥梁,但数量超过友军:
else if(isComingOveBridge && friendlyTroops < enemyTroops)
然后,第三种也是最后一种可能的输出,如果没有其他任何一个是true
,则由最后的else
捕获,而不需要if
条件。
读者挑战
你能否发现前面代码中的缺陷?一个可能会让一群缺乏经验的士兵陷入混乱的缺陷?没有明确处理敌军和友军数量完全相等的情况,因此这种情况将由最后的else
处理。最后的else
是为了处理没有敌军的情况。我想任何有自尊的指挥官都会期望他的士兵在这种情况下战斗。他可以将第一个if
语句改为适应这种情况,如下所示:
if(isComingOverBridge && friendlyTroops >= enemyTroops)
最后,指挥官最后的担忧是,如果敌军挥舞着白旗投降并迅速被屠杀,那么他的士兵最终会成为战争罪犯。所需的 C++代码很明显。使用wavingWhiteFlag
布尔变量,他编写了以下测试:
if (wavingWhiteFlag)
{
// Take prisoners
}
但将这段代码放在哪里并不那么清楚。最后,指挥官选择了以下嵌套解决方案,并将wavingWhiteFlag
的测试改为逻辑非,如下所示:
if (!wavingWhiteFlag)
{
// not surrendering so check everything else
if(isComingOverTheBridge && friendlyTroops >= enemyTroops)
{
// shoot them
}
else if(isComingOverTheBridge && friendlyTroops < enemyTroops)
{
// blow the bridge
}
}
else
{
// this is the else for our first if
// Take prisoners
}
// Holding position
这证明了我们可以将if
和else
语句嵌套在一起,以创建相当深入和详细的决策。
我们可以用if
和else
继续做出越来越复杂的决策,但我们所看到的已经足够作为入门了。也许值得指出的是,通常,解决问题有不止一种方法。正确的方法通常是以最清晰、最简单的方式解决问题的方法。
我们越来越接近拥有所有必要的 C++ 知识来动画化我们的云和蜜蜂。我们还有一个最后的动画问题要讨论,然后我们可以回到游戏。
时间控制
在我们移动蜜蜂和云之前,我们需要考虑时间控制。正如我们已经知道的,主游戏循环会重复执行,直到玩家按下 Escape 键。
我们还了解到 C++ 和 SFML 非常快。事实上,我那台老化的笔记本电脑以大约每秒五千次的速度执行一个简单的游戏循环(就像当前的循环)。
帧率问题
让我们考虑蜜蜂的速度。为了讨论的目的,我们可以假装我们将以每秒 200 像素的速度移动它。在一个宽度为 1,920 像素的屏幕上,它将非常近似地需要 10 秒才能穿越整个宽度,因为 10 乘以 200 等于 2,000(接近 1,920)。
此外,我们知道我们可以使用 setPosition(...,...)
来定位任何我们的精灵。我们只需要将 x 和 y 坐标放入括号中。
除了设置精灵的位置外,我们还可以获取精灵的当前位置。例如,要获取蜜蜂的水平 x 坐标,我们会使用以下代码:
int currentPosition = spriteBee.getPosition().x;
蜜蜂当前的 x 坐标现在存储在 currentPosition
中。要将蜜蜂向右移动,我们需要将 200(我们期望的速度)除以 5,000(在我的笔记本电脑上大约每秒的帧数)的适当分数加到 currentPosition
上,如下所示:
currentPosition += 200/5000;
现在,我们将使用 setPosition
来移动我们的蜜蜂。它将以每帧 200 除以 5,000 像素的速度平滑地从左向右移动。但这种方法有两个大问题。
帧率是每秒我们游戏循环处理的次数。也就是说,我们处理玩家输入、更新游戏对象并将它们绘制到屏幕上的次数。我们现在将扩展并讨论帧率的问题,并在整本书的其余部分进行讨论。
我的笔记本电脑上的帧率可能并不总是恒定的。蜜蜂可能看起来像是在屏幕上间歇性地“加速”。
当然,我们希望我们的游戏比我的笔记本电脑拥有更广泛的受众!每台 PC 的帧率都会有所不同,至少有一点不同。如果你有一台旧电脑,蜜蜂看起来就像是被铅压得沉重,而如果你有最新的游戏装备,它可能更像是一个模糊的涡轮蜜蜂。
幸运的是,这个问题对每个游戏都是一样的,SFML 已经提供了一个解决方案。理解这个解决方案的最简单方法就是实现它。
SFML 帧率解决方案
现在,我们将测量并使用帧率来控制我们的游戏。为了开始实现这一点,在主游戏循环之前添加以下代码:
// How fast is each cloud?
float cloud1Speed = 0;
float cloud2Speed = 0;
float cloud3Speed = 0;
// Variables to control time itself
Clock clock;
while (window.isOpen())
{
在之前的代码中,我们声明了一个Clock
类型的对象,并将其命名为clock
。类名以大写字母开头,而对象名(我们实际会使用)以小写字母开头。对象名是任意的,但clock
似乎是一个合适的名字,因为,嗯,它是时钟。我们很快还会添加一些更多与时间相关的变量。
现在,在我们的游戏代码的更新部分,添加以下突出显示的代码:
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
/*
****************************************
Draw the scene
****************************************
*/
如你所预期的那样,clock.restart()
函数重新启动时钟。我们希望每帧都重新启动时钟,这样我们就可以测量每一帧和每一帧的持续时间。此外,它还返回自上次我们重新启动时钟以来经过的时间量。
因此,在之前的代码中,我们声明了一个名为dt
的Time
类型对象,并使用它来存储clock.restart()
函数返回的值。
现在,我们有一个名为dt
的Time
对象,它包含了自上次我们更新场景和重新启动时钟以来经过的时间量。也许你能看到这会走向何方?我们将使用每一帧的经过时间来控制蜜蜂和云朵移动的距离。
让我们在游戏中添加更多代码,并使用我们迄今为止学到的关于操作变量、生成随机数、if
关键字和else
关键字的一切。然后,我们将看到如何使用Clock
对象和dt
克服帧率问题。
重要提示
dt
代表delta time,即两次更新之间的时间。
移动云朵和蜜蜂
让我们使用上一帧以来的经过时间来为蜜蜂和云朵注入生命。这将解决在不同 PC 上保持一致帧率的问题。
为蜜蜂注入生命
我们首先想做的事情是设置蜜蜂在某个高度和某个速度。我们只想在蜜蜂不活跃时这样做。因此,我们将以下代码包裹在一个if
块中。检查并添加以下突出显示的代码,然后我们将讨论它:
/*
****************************************
Update the scene
****************************************
*/
// Measure time
Time dt = clock.restart();
// Setup the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0));
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 500) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
/*
****************************************
Draw the scene
****************************************
*/
现在,如果蜜蜂不活跃,就像游戏一开始时不会那样,if(!beeActive)
将是true
,前面的代码将按以下顺序执行以下操作:
-
初始化随机数生成器。
-
获取一个介于 200 和 399 之间的随机数,并将结果分配给
beeSpeed
。 -
再次初始化随机数生成器。
-
获取一个介于 500 和 999 之间的随机数,并将结果分配给一个新的名为
height
的float
变量。 -
将蜜蜂的位置设置在 x 轴上的
2000
(刚好在屏幕右侧之外)以及 y 轴上的任意height
。 -
将
beeActive
设置为 true。重要提示
注意,
height
变量是我们第一次在游戏循环内部声明的变量。此外,因为它是在if
块内部声明的,所以实际上在if
块外部是“不可见”的。这对我们的使用来说是可以的,因为一旦我们设置了蜜蜂的高度,我们就不再需要它了。这种现象影响变量的是称为 作用域 的现象。我们将在 第四章 中更全面地探讨这个问题,循环、数组、开关、枚举和函数 – 实现游戏机制。
如果我们运行游戏,蜜蜂目前不会有任何变化,但现在蜜蜂是活跃的,我们可以编写一些在 beeActive
为 true
时运行的代码。
添加以下突出显示的代码,如您所见,当 beeActive
为 true
时执行。这是因为它在 if(!beeActive)
块之后跟着 else
:
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) );
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
{
spriteBee.setPosition(
spriteBee.getPosition().x -
(beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Has the bee reached the left-hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
在 else
块中,以下事情会发生。
蜜蜂的位置是通过以下标准改变的。setPosition
函数使用 getPosition
函数获取蜜蜂当前的横向坐标。然后从这个坐标中减去 beeSpeed * dt.asSeconds()
。
beeSpeed
变量的值是每秒许多像素,并在之前的 if
块中随机分配。dt.asSeconds()
的值将是一个代表上一帧动画持续时间的 1 的分数。
假设蜜蜂当前的横向坐标是 dt.asSeconds
,如果将 beeSpeed
设置为最大值,setPosition
用于横向坐标的计算方式如下:
1000 - 0.0002 x 399
因此,蜜蜂在水平轴上的新位置将是 999.9202。我们可以看到蜜蜂非常、非常平滑地向左漂移,每帧不到一个像素。如果帧率波动,则公式将产生一个新值以适应。如果我们在一个每秒只能达到 100 帧的 PC 上运行相同的代码,或者在一个每秒可以达到一百万帧的 PC 上运行,蜜蜂将以相同的速度移动。
setPosition
函数使用 getPosition().y
来确保蜜蜂在整个活跃周期内保持完全相同的垂直坐标。
我们刚刚添加的 else
块中的代码的最后部分如下:
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
此代码在每个帧(当 beeActive
为 true
时)测试蜜蜂是否已经从屏幕的左侧消失。如果 getPosition
函数返回值小于 -100,它肯定会超出玩家的视野。当这种情况发生时,beeActive
被设置为 false
,在下一帧,将设置一个“新”的蜜蜂以新的随机高度和新的随机速度飞行。
尝试运行游戏并观察我们的蜜蜂尽职尽责地从右向左飞行,然后在新的高度和速度下再次回到右侧。几乎每次都像是一只新蜜蜂。
小贴士
当然,一只真正的蜜蜂会长时间围绕你转,在你试图集中精力砍树时打扰你。我们将在后续的项目中制作一些更智能的游戏角色。
现在,我们将以非常相似的方式使云彩移动。
吹散云彩
我们首先想要做的是将第一朵云设置在特定的高度和速度。我们只想在云彩不活跃时这样做。因此,我们将接下来的代码包裹在 if
块中。检查并添加以下突出显示的代码,就在我们为蜜蜂添加的代码之后,然后我们将讨论它。它与我们在蜜蜂上使用的代码几乎相同:
else
// Move the bee
{
spriteBee.setPosition(
spriteBee.getPosition().x -
(beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Has the bee reached the right hand edge of the screen?
if (spriteBee.getPosition().x < -100)
{
// Set it up ready to be a whole new bee next frame
beeActive = false;
}
}
// Manage the clouds
// Cloud 1
if (!cloud1Active)
{
// How fast is the cloud
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
/*
****************************************
Draw the scene
****************************************
*/
我们刚刚添加的代码与与蜜蜂相关的代码之间的唯一区别是我们处理不同的精灵,并使用不同的随机数范围。此外,我们将 time(0)
返回的结果乘以十 (* 10
),以确保每个云彩都能得到不同的种子。当我们编写其他云彩移动时,你会看到我们分别使用 * 20
和 * 30
。
现在,当云彩活跃时,我们可以采取行动。我们将在 else
块中这样做。与 if
块一样,代码与与蜜蜂相关的代码相同,只是所有代码都针对云彩而不是蜜蜂:
// Manage the clouds
if (!cloud1Active)
{
// How fast is the cloud
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
else
{
spriteCloud1.setPosition(
spriteCloud1.getPosition().x +
(cloud1Speed * dt.asSeconds()),
spriteCloud1.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud1.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud1Active = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
现在我们知道了该怎么做,我们可以为第二和第三朵云复制相同的代码。在第一朵云的代码之后立即添加以下突出显示的代码,以处理第二和第三朵云:
...
// Cloud 2
if (!cloud2Active)
{
// How fast is the cloud
srand((int)time(0) * 20);
cloud2Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 20);
float height = (rand() % 300) - 150;
spriteCloud2.setPosition(-200, height);
cloud2Active = true;
}
else
{
spriteCloud2.setPosition(
spriteCloud2.getPosition().x +
(cloud2Speed * dt.asSeconds()),
spriteCloud2.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud2.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud2Active = false;
}
}
if (!cloud3Active)
{
// How fast is the cloud
srand((int)time(0) * 30);
cloud3Speed = (rand() % 200);
// How high is the cloud
srand((int)time(0) * 30);
float height = (rand() % 450) - 150;
spriteCloud3.setPosition(-200, height);
cloud3Active = true;
}
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
/*
****************************************
Draw the scene
****************************************
*/
现在,你可以运行游戏,云彩将随机且连续地在屏幕上飘动。蜜蜂也会在重生之前从右向左嗡嗡作响。以下截图显示了本章我们所取得的成果:
小贴士
所有这些云彩和蜜蜂的处理看起来有点重复吗?我们将看到如何节省大量输入并使我们的代码更易于阅读,因为在 C++ 中,有处理相同类型变量或对象多个实例的方法。其中一种方法称为 数组,我们将在 第四章 中学习它们,即循环、数组、开关、枚举和函数 – 实现游戏机制。在这个项目的最后,一旦我们学习了数组,我们将讨论如何改进我们的云彩代码。
查看与本章主题相关的一些常见问题。
摘要
在本章中,我们了解到变量是内存中的一个命名存储位置,我们可以在这里存储特定类型的值。这些类型包括 int
、float
、double
、bool
、String
和 char
。
我们可以声明和初始化所有存储游戏数据的变量。一旦我们有了这些变量,我们可以使用算术和赋值运算符来操作它们,并在逻辑运算符的测试中使用它们。与 if
和 else
关键字结合使用,我们可以根据游戏中的当前情况分支我们的代码。
利用所有这些新的知识,我们制作了一些云朵和一只蜜蜂的动画。在下一章中,我们将运用这些技能来添加抬头显示(HUD)和为玩家添加更多输入选项,以及使用时间条来直观地表示时间。
FAQ
Q) 为什么当蜜蜂到达-100 时我们将其设置为非活动状态?为什么不直接设置为 0,因为 0 是窗口的左侧?
A) 蜜蜂图形的宽度是 60 像素,其原点位于左上角的像素。因此,当蜜蜂以其原点在 x 等于零的位置被绘制时,整个蜜蜂图形仍然在屏幕上,玩家可以看到。通过等待它到达-100,我们可以确保它已经超出了玩家的视野。
Q) 我如何知道我的游戏循环运行得多快?
A) 如果你有一张现代的 NVIDIA 显卡,你可能已经可以通过配置你的 GeForce Experience 叠加层来显示帧率。然而,要使用我们自己的代码明确地测量这个值,我们还需要了解一些其他的事情。我们将在第五章**,碰撞、声音和结束条件 – 使游戏可玩中添加测量和显示当前帧率的功能。
第四章:第三章:C++ 字符串和 SFML 时间 – 玩家输入和 HUD
在本章中,我们将继续开发 Timber!!游戏。我们将用大约一半的时间学习如何操作文本并在屏幕上显示它,另一半时间将探讨计时以及如何通过视觉时间条让玩家了解剩余时间,并在游戏中创造紧迫感。
我们将涵盖以下主题:
-
暂停和重新启动游戏
-
C++ 字符串
-
SFML 文本和 SFML 字体类
-
为 Timber!!!添加一个 HUD
-
为 Timber!!!添加时间条
暂停和重新启动游戏
在接下来的三章中,我们将会对这款游戏进行开发,代码显然会越来越长。因此,现在似乎是提前思考并给我们的代码添加更多结构的好时机。我们将添加这种结构,以便我们可以暂停和重新启动游戏。
我们将添加代码,使得当游戏第一次运行时,它将处于暂停状态。玩家随后可以按下Enter键来开始游戏。然后,游戏将继续运行,直到玩家被压扁或用完时间。此时,游戏将暂停并等待玩家按下Enter键,以便他们可以重新启动游戏。
让我们一步一步地设置这个变量。
首先,在主游戏循环外部声明一个新的bool
变量,命名为paused
,并将其初始化为true
:
// Variables to control time itself
Clock clock;
// Track whether the game is running
bool paused = true;
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
现在,每次游戏运行时,我们都有一个paused
变量,它的初始值是true
。
接下来,我们将添加另一个if
语句,其中表达式将检查Enter键是否当前被按下。如果是被按下,它将paused
设置为false
。在其他的键盘处理代码之后添加以下突出显示的代码:
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
}
/*
****************************************
Update the scene
****************************************
*/
现在,我们有一个名为paused
的bool
变量,它一开始是true
,但当玩家按下Enter键时,它将变为false
。在这个时候,我们必须让我们的游戏循环根据paused
的当前值做出适当的响应。
我们将这样进行。我们将整个更新部分的代码,包括我们在上一章中编写的移动蜜蜂和云的代码,包裹在一个if
语句中。
在下面的代码中,请注意,只有当paused
不等于true
时,if
块才会执行。换句话说,当游戏处于暂停状态时,游戏不会移动/更新。
这正是我们想要的。仔细看看我们添加新if
语句及其对应的开闭花括号{...}
的位置。如果它们放在错误的位置,事情将不会按预期工作。
将以下突出显示的代码添加到更新部分的代码中,注意上下文。我在几行代码中添加了...
来表示隐藏的代码。显然,...不是真正的代码,不应该添加到游戏中。你可以通过周围的未突出显示代码来确定放置新代码(突出显示)的开始和结束位置:
/*
****************************************
Update the scene
****************************************
*/
if (!paused)
{
// Measure time
...
...
...
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
注意,当你放置新的 if
块的闭合花括号时,Visual Studio 会整洁地调整所有缩进来保持代码整洁。
现在,你可以运行游戏,直到你按下 Enter 键,一切都将保持静态。现在,你可以开始添加游戏功能。我们只需要记住,当玩家死亡或用完时间时,我们需要将 paused
设置为 true
。
在上一章中,我们第一次了解了 C++ 字符串。我们需要了解它们更多,以便我们可以实现玩家的 HUD。
C++ 字符串
在上一章中,我们简要介绍了字符串,并了解到字符串可以存储字母数字数据——从单个字符到整本书。我们没有查看声明、初始化或操作字符串,所以现在让我们来做这件事。
声明字符串
声明字符串变量很简单。这与我们在上一章中用于其他变量的过程相同:我们声明类型,然后是名称:
String levelName;
String playerName;
一旦我们声明了一个字符串,我们就可以给它赋值。
为字符串赋值
要为字符串赋值,就像常规变量一样,我们只需写出名称,然后是赋值运算符,最后是值:
levelName = "DastardlyCave";
playerName = "John Carmack";
注意,值需要用引号括起来。就像常规变量一样,我们也可以在一行中声明和赋值:
String score = "Score = 0";
String message = "GAME OVER!!";
在下一节中,我们将看到如何更改我们的字符串变量的值。
字符串操作
我们可以使用 #include <sstream>
指令为我们提供一些额外的字符串操作选项。sstream
类允许我们将一些字符串“相加”。当我们把字符串相加时,这被称为连接:
String part1 = "Hello ";
String part2 = "World";
sstream ss;
ss<< part1 << part2;
// ss now holds "Hello World"
此外,通过使用 sstream
对象,字符串变量甚至可以与不同类型的变量连接。以下代码开始揭示字符串可能对我们有多有用:
String scoreText = "Score = ";
int score = 0;
// Later in the code
score ++;
sstream ss;
ss<<scoreText<< score;
// ss now holds "Score = 1"
在前面的代码中,ss
用于将 scoreText
的内容与 score
的值连接起来。请注意,尽管 score
保持一个 int
值,但 ss
最终持有的值仍然是一个包含等效值的字符串;在这种情况下,"1"。
小贴士
<<
运算符是位运算符之一。然而,C++ 允许你编写自己的类并覆盖在类上下文中特定运算符的行为。sstream
类就是这样做的,以便 <<
运算符按这种方式工作。复杂性被隐藏在类中。我们可以使用其功能而不必担心它是如何工作的。如果你感到好奇,你可以阅读有关运算符重载的信息,请参阅 www.tutorialspoint.com/cplusplus/cpp_overloading.htm
。为了继续项目,你不需要更多的信息。
现在我们已经了解了 C++ 字符串的基础以及如何使用 sstream
,我们将看看如何使用一些 SFML 类在屏幕上显示它们。
SFML 的文本和字体类
在我们继续添加游戏代码之前,让我们通过一些假设的代码来讨论Text
和Font
类。
在屏幕上绘制文本的第一步是拥有一个字体。在第一章,C++、SFML、Visual Studio 和开始第一个游戏中,我们将字体文件添加到了项目文件夹中。现在,我们可以将字体加载到 SFML Font
对象中,使其准备好使用。
实现此目的的代码如下:
Font font;
font.loadFromFile("myfont.ttf");
在前面的代码中,我们首先声明了Font
对象,然后加载了一个实际的字体文件。请注意,myfont.ttf
是一个假设的字体,我们可以在项目文件夹中使用任何字体。
一旦我们加载了一个字体,我们需要一个 SFML Text
对象:
Text myText;
现在,我们可以配置我们的Text
对象。这包括大小、颜色、屏幕上的位置、包含消息的字符串,以及当然,将其与我们的font
对象关联的操作:
// Assign the actual message
myText.setString("Press Enter to start!");
// assign a size
myText.setCharacterSize(75);
// Choose a color
myText.setFillColor(Color::White);
// Set the font to our Text object
myText.setFont(font);
现在我们能够创建和操作字符串值,以及分配、声明和初始化 SFML Text
对象,我们可以继续到下一部分,在那里我们将为 Timber 添加 HUD!!!
实现 HUD
现在,我们对字符串、SFML Text
和 SFML Font
有了足够的了解,可以着手实现 HUD。HUD代表抬头显示。它可以像屏幕上的分数和文本消息那样简单,也可以包括更复杂元素,如时间条、小地图或指南针,代表玩家角色面对的方向。
要开始使用 HUD,我们需要在代码文件顶部添加另一个#include
指令以添加对sstream
类的访问。正如我们已知的,sstream
类为将字符串和其他变量类型组合成字符串添加了一些非常实用的功能。
添加以下高亮代码行:
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
int main()
{
接下来,我们将设置我们的 SFML Text
对象:一个用于存储我们将根据游戏状态变化的消息,另一个将存储分数并需要定期更新。
代码声明了Text
和Font
对象,加载了字体,将字体分配给Text
对象,然后添加了字符串消息、颜色和大小。这应该与我们在上一节中的讨论相似。此外,我们添加了一个名为score
的新int
变量,我们可以操作它,使其包含玩家的分数。
小贴士
记住,如果你在第一章**C++、SFML、Visual Studio 和开始第一个游戏中选择了不同的字体KOMIKAP_.ttf
,你需要将代码中相应部分更改为与Visual Studio Stuff/Projects/Timber/fonts
文件夹中的.ttf
文件匹配。
通过添加以下高亮代码,我们将准备好继续更新 HUD:
// Track whether the game is running
bool paused = true;
// Draw some text
int score = 0;
Text messageText;
Text scoreText;
// We need to choose a font
Font font;
font.loadFromFile("fonts/KOMIKAP_.ttf");
// Set the font to our message
messageText.setFont(font);
scoreText.setFont(font);
// Assign the actual message
messageText.setString("Press Enter to start!");
scoreText.setString("Score = 0");
// Make it really big
messageText.setCharacterSize(75);
scoreText.setCharacterSize(100);
// Choose a color
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
在前面的代码中,我们实现了以下内容:
-
声明一个变量来保存分数
-
声明了一些 SFML
Text
和Font
对象 -
通过从文件中加载字体初始化
Font
对象 -
使用字体和一些字符串初始化
Text
对象 -
使用
setCharacterSize
和setFillColor
函数设置Text
对象的大小和颜色
以下代码片段可能看起来有些复杂,甚至有些复杂。然而,当你稍微分解它时,它却是直截了当的。检查并添加新的突出显示的代码。我们将在之后讨论它:
// Choose a color
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
while (window.isOpen())
{
/*
****************************************
Handle the players input
****************************************
*/
我们有两个Text
类型的对象,我们将在屏幕上显示。我们希望将scoreText
定位在左上角,并留有一定的填充。这并不构成挑战;我们只需使用scoreText.setPosition(20, 20)
,这样它就会定位在左上角,水平方向和垂直方向各有 20 像素的填充。
然而,定位messageText
并不那么简单。我们希望将其定位在屏幕的精确中点。最初,这可能看起来不是问题,但我们必须记住,我们绘制的一切的原点都在左上角。因此,如果我们简单地将屏幕宽度和高度除以二,并在mesageText.setPosition...
中使用结果,那么文本的左上角就会在屏幕中心,并且它将杂乱无章地向右扩展。
以下是为了方便再次讨论的代码:
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
代码所做的就是将messageText
的中心设置为屏幕的中心。我们正在审查的看起来相当复杂的代码片段重新定位了messageText
的原点到其自身中心。
在前面的代码中,我们首先声明了一个名为textRect
的新对象,其类型为FloatRect
。正如其名称所暗示的,FloatRect
对象包含一个具有浮点坐标的矩形。
然后,代码使用mesageText.getLocalBounds
函数用包围messageText
的矩形的坐标初始化textRect
。
下一行代码,由于它相当长,被分散在四行中,使用了messageText.setOrigin
函数来改变原点(用于绘制的点)到textRect
的中心。当然,textRect
包含一个与messageText
坐标相匹配的矩形。然后,执行以下代码行:
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
现在,messageText
将被整齐地定位在屏幕的精确中心。我们将使用此代码每次更改messageText
的文本,因为更改消息会改变messageText
的大小,因此需要重新计算其原点。
接下来,我们声明了一个名为ss
的stringstream
类型对象。请注意,我们使用了包括命名空间在内的完整名称,即std::stringstream
。我们可以通过在代码文件顶部添加using namespace std
来避免这种语法。不过,在这里我们不会这样做,因为我们很少使用它。请查看以下代码并将其添加到游戏中;然后,我们可以更详细地讨论它。由于我们只想在游戏未暂停时执行此代码,请确保将其与其他代码一起添加到if(!paused)
块中,如下所示:
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Has the cloud reached the right hand edge of the screen?
if (spriteCloud3.getPosition().x > 1920)
{
// Set it up ready to be a whole new cloud next frame
cloud3Active = false;
}
}
// Update the score text
std::stringstream ss;
ss<< "Score = " << score;
scoreText.setString(ss.str());
}// End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
我们使用ss
和<<
运算符提供的特殊功能,该运算符将变量连接到一个stringstream
中。在这里,ss << "Score = " << score
的效果是创建一个包含"Score = "
的字符串。无论score
的值是多少,都会被连接在一起。例如,当游戏第一次开始时,score
等于零,所以ss
将保持"Score = 0"
的值。如果score
发生变化,ss
将适应每一帧。
以下行代码只是将ss
中包含的字符串设置为scoreText
:
scoreText.setString(ss.str());
现在它已经准备好被绘制到屏幕上了。
以下代码绘制了两个Text
对象(scoreText
和messageText
),但绘制messageText
的代码被包裹在一个if
语句中。这个if
语句导致messageText
只有在游戏暂停时才会被绘制。
添加以下高亮代码:
// Now draw the insect
window.draw(spriteBee);
// Draw the score
window.draw(scoreText);
if (paused)
{
// Draw our message
window.draw(messageText);
}
// Show everything we just drew
window.display();
我们现在可以运行游戏,并看到我们的 HUD 被绘制到屏幕上。你会看到得分 = 0和按回车键开始的消息。当你按下Enter键时,后者将消失:
如果你想要看到分数更新,请在while(window.isOpen)
循环中的任何地方添加一个临时行代码,score ++;
。如果你添加了这个临时行代码,你会看到分数快速上升,非常快!
如果你添加了临时代码,即score ++;
,在继续之前务必将其删除。
添加时间条
由于时间在游戏中是一个关键机制,因此有必要让玩家意识到这一点。他们需要知道他们分配的六秒是否即将用完。当游戏接近结束时,这会给他们一种紧迫感;如果他们表现良好,能够保持或增加剩余时间,这会给他们一种成就感。
在屏幕上绘制剩余秒数不易阅读(当专注于分支时),也不是实现目标的一种特别有趣的方式。
我们需要的是一个时间条。我们的时间条将是一个简单且突出显示在屏幕上的红色矩形。它一开始会很宽,但随着时间的流逝会迅速缩小。当玩家的剩余时间达到零时,时间条将完全消失。
同时添加时间条时,我们还将添加必要的代码来跟踪玩家的剩余时间,并在时间用尽时做出响应。让我们一步一步地完成这个过程。
找到之前声明的Clock clock;
,在其后添加高亮代码,如下所示:
// Variables to control time itself
Clock clock;
// Time bar
RectangleShape timeBar;
float timeBarStartWidth = 400;
float timeBarHeight = 80;
timeBar.setSize(Vector2f(timeBarStartWidth, timeBarHeight));
timeBar.setFillColor(Color::Red);
timeBar.setPosition((1920 / 2) - timeBarStartWidth / 2, 980);
Time gameTimeTotal;
float timeRemaining = 6.0f;
float timeBarWidthPerSecond = timeBarStartWidth / timeRemaining;
// Track whether the game is running
bool paused = true;
首先,我们声明一个RectangleShape
类型的对象,并将其命名为timeBar
。RectangleShape
是 SFML 类,非常适合绘制简单的矩形。
接下来,我们将添加几个float
类型的变量,timeBarStartWidth
和timeBarHeight
。我们将它们分别初始化为400
和80
。这些变量将帮助我们跟踪在每一帧绘制timeBar
所需的大小。
接下来,我们使用timeBar.setSize
函数设置timeBar
的大小。我们不仅仅传递两个新的float
变量。首先,我们创建一个新的Vector2f
类型的对象。然而,这里的不同之处在于我们没有给这个新对象命名。相反,我们直接用两个浮点变量初始化它,并将其直接传递给setSize
函数。
提示
Vector2f
是一个包含两个float
变量的类。它还有一些其他功能,将在本书的其余部分介绍。
之后,我们通过使用setFillColor
函数将timeBar
涂成红色。
在之前的代码中我们对timeBar
做的最后一件事是设置其位置。垂直坐标非常直接,但设置水平坐标的方式稍微复杂一些。这里再次进行计算:
(1920 / 2) - timeBarStartWidth / 2
首先,代码将 1920 除以 2。然后,它将timeBarStartWidth
除以 2。最后,它从前者减去后者。
结果使timeBar
整齐地、水平地位于屏幕中央。
我们正在讨论的最后三行代码声明了一个名为gameTimeTotal
的新Time
对象,一个初始化为6
的新float
变量timeRemaining
,以及一个听起来很奇怪的名为timeBarWidthPerSecond
的float
变量,我们将在下一节讨论。
timeBarWidthPerSecond
变量通过将timeBarStartWidth
除以timeRemaining
初始化。结果是timeBar
每秒需要缩小的像素数。当我们在每一帧调整timeBar
大小时,这将很有用。
显然,每次玩家开始新游戏时,我们都需要重置剩余时间。进行此操作的逻辑位置是在按下Enter键时。我们还可以同时将score
设置回零。现在让我们添加以下突出显示的代码:
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 6;
}
现在,我们必须通过剩余时间减少每一帧,并相应地调整timeBar
的大小。将以下突出显示的代码添加到更新部分,如下所示:
/*
****************************************
Update the scene
****************************************
*/
if (!paused)
{
// Measure time
Time dt = clock.restart();
// Subtract from the amount of time remaining
timeRemaining -= dt.asSeconds();
// size up the time bar
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
首先,我们通过以下代码减去玩家剩余的时间与上一帧执行所需的时间量:
timeRemaining -= dt.asSeconds();
然后,我们使用以下代码调整了timeBar
的大小:
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
Vector2F
的 x 值在乘以timeRemaining
时初始化为timebarWidthPerSecond
,这会产生与玩家剩余时间成正比的正确宽度。高度保持不变,timeBarHeight
未经过任何操作就被使用。
当然,我们必须检测时间是否已耗尽。目前,我们将简单地检测时间是否已耗尽,暂停游戏,并更改messageText
的文本。稍后,我们将在这里做更多的工作。将以下突出显示的代码添加到之前添加的代码之后。我们稍后会详细讨论它:
// Measure time
Time dt = clock.restart();
// Subtract from the amount of time remaining
timeRemaining -= dt.asSeconds();
// resize up the time bar
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
if (timeRemaining<= 0.0f) {
// Pause the game
paused = true;
// Change the message shown to the player
messageText.setString("Out of time!!");
//Reposition the text based on its new size
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
}
// Set up the bee
if (!beeActive)
{
// How fast is the bee
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// How high is the bee
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Move the bee
让我们逐步分析之前的代码:
-
首先,我们使用
if(timeRemaining<= 0.0f)
测试时间是否已耗尽。 -
然后,我们将
paused
设置为true
,因此这将是我们代码更新部分的最后一次执行(直到玩家再次按下 Enter 键)。 -
然后,我们更改
messageText
的信息,计算其新的中心并将其设置为原点,并将其定位在屏幕中央。
最后,对于这段代码,我们需要绘制 timeBar
。这段代码中没有我们之前没有多次见过的内容。只需注意,我们在绘制树之后绘制 timeBar
,这样它就不会被部分遮挡。添加以下高亮代码以绘制时间条:
// Draw the score
window.draw(scoreText);
// Draw the timebar
window.draw(timeBar);
if (paused)
{
// Draw our message
window.draw(messageText);
}
// Show everything we just drew
window.display();
现在,你可以运行游戏,按下 Enter 键开始它,并观察时间条平滑地消失到无:
游戏随后暂停,屏幕中央将出现 OUT OF TIME!! 信息:
当然,你可以按下 Enter 键重新开始游戏,并从开始观看它运行。
摘要
在本章中,我们学习了字符串、SFML Text
和 SFML Font
。它们共同使我们能够在屏幕上绘制文本,为玩家提供了抬头显示(HUD)。我们还使用了 sstream
,它允许我们将字符串和其他变量连接起来以显示分数。
我们还探讨了 SFML 的 RectangleShape
类,它确实如其名称所暗示的那样。我们使用 RectangleShape
类型的对象和一些精心策划的变量来绘制一个时间条,它以视觉方式向玩家显示他们剩余的时间。一旦我们实现了可以挤压玩家的砍伐和移动树枝,时间条将提供视觉反馈,从而创造紧张和紧迫感。
在下一章中,我们将学习一系列新的 C++ 功能,包括循环、数组、切换、枚举和函数。这将使我们能够移动树枝,跟踪它们的位置,并挤压玩家。
常见问题解答
Q) 我可以预见,有时通过精灵的左上角定位可能会不方便。有没有替代方案?
A) 幸运的是,你可以选择使用精灵的哪个点作为定位/原点像素,就像我们使用 messageText
时做的那样,通过使用 setOrigin
函数。
Q) 代码变得越来越长,我很难跟踪所有内容的位置。我们该如何解决这个问题?
A) 是的,我同意。在下一章中,我们将探讨几种组织代码和使其更易读的方法之一。我们将在我们学习编写 C++ 函数时探讨这一点。此外,当我们学习 C++ 数组时,我们还将了解一种处理相同类型多个对象/变量的新方法(如云)。
第五章:第四章:循环、数组、开关、枚举和函数——实现游戏机制
本章可能比本书中任何其他章节都包含更多的 C++信息。它充满了基本概念,将极大地推动我们的理解。它还将开始揭示我们一直略过的一些模糊区域,例如函数和游戏循环。
一旦我们探索了整个 C++语言必需品的列表,我们就会使用我们所知道的一切来制作主要游戏机制——树分支。到本章结束时,我们将为最终阶段和 Timber!!!的完成做好准备。
在本章中,我们将涵盖以下主题:
-
循环
-
数组
-
使用
switch
做出决定 -
枚举
-
开始使用函数
-
创建和移动树分支
循环
在编程中,我们经常需要多次执行相同的事情。我们迄今为止看到的明显例子是游戏循环。去掉所有代码后,我们的游戏循环看起来像这样:
while (window.isOpen())
{
}
有几种不同的循环类型,我们在这里将查看最常用的几种。这种类型的循环的正确术语是while循环。
循环
while
循环相当简单。回想一下if
语句及其评估为true
或false
的表达式。我们可以在while
循环的条件表达式中使用完全相同的运算符和变量的组合。
与if
语句类似,如果表达式为true
,则代码执行。然而,与while
循环的区别在于,其中的 C++代码将反复执行,直到条件为false
。看看下面的代码。
int numberOfZombies = 100;
while(numberOfZombies > 0)
{
// Player kills a zombie
numberOfZombies--;
// numberOfZombies decreases each pass through the loop
}
// numberOfZombies is no longer greater than 0
让我们回顾一下前一段代码中发生的事情。在while
循环外部,int numberOfZombies
被声明并初始化为100
。然后,while
循环开始。它的条件表达式是numberOfZombies > 0
。因此,while
循环将继续循环通过其体内的代码,直到条件评估为false
。这意味着前面的代码将执行 100 次。
在循环的第一遍中,numberOfZombies
等于 100,然后是 99,然后是 98,以此类推。但是一旦numberOfZombies
等于零,它当然就不再是大于零了。然后,代码将跳出while
循环,并在闭合的大括号之后继续运行。
就像if
语句一样,while
循环甚至可能一次都不会执行。看看下面的代码:
int availableCoins = 10;
while(availableCoins > 10)
{
// more code here.
// Won't run unless availableCoins is greater than 10
}
在while
循环内部的先前代码将不会执行,因为条件是错误的。
注意,表达式或循环体内的代码量的复杂性没有限制。考虑以下假设的游戏循环变体:
int playerLives = 3;
int alienShips = 10;
while(playerLives !=0 && alienShips !=0 )
{
// Handle input
// Update the scene
// Draw the scene
}
// continue here when either playerLives or alienShips equals 0
前面的while
循环将继续执行,直到playerLives
或alienShips
等于零。一旦这些条件中的任何一个发生,表达式就会评估为false
,程序将继续从while
循环后的第一行代码执行。
值得注意的是,一旦进入循环体,它将始终至少执行一次,即使表达式在中间评估为假,因为它不会再次测试,直到代码尝试开始另一轮。让我们看看一个例子:
int x = 1;
while(x > 0)
{
x--;
// x is now 0 so the condition is false
// But this line still runs
// and this one
// and me!
}
// Now I'm done!
前面的循环体将执行一次。我们还可以设置一个将永远运行的while
循环,不出所料,它被称为无限循环。以下是一个示例:
int y = 0;
while(true)
{
y++; // Bigger... Bigger...
}
如果你觉得前面的循环很复杂,只需字面理解它。循环在其条件为true
时执行。嗯,true
始终是true
,因此会一直执行。
从while
循环中跳出
我们可能会使用一个无限循环,这样我们就可以在循环体内决定何时退出循环,而不是在表达式中。我们可以通过在准备好离开循环体时使用break关键字来实现,可能如下所示:
int z = 0;
while(true)
{
z++; // Bigger... Bigger...
break; // No you're not
// Code doesn't reach here
}
在前面的代码中,循环体内的代码将执行一次,直到包括break
语句在内,然后执行将继续在while
循环的闭合花括号之后。
如你所猜测,我们可以在while
循环和其他循环类型中结合任何 C++决策工具,如if
、else
,以及我们很快将要学习的另一个名为switch
的工具。考虑以下示例:
int x = 0;
int max = 10;
while(true)
{
x++; // Bigger... Bigger...
if(x == max){
break;
} // No you're not
// code reaches here only until max = 10
}
在前面的代码中,if
条件决定何时执行break
语句。在这种情况下,代码将一直循环,直到max
达到 10。
我们可以长时间地查看 C++ while
循环的各种排列组合,但,在某个时候,我们希望回到制作游戏。所以,让我们继续讨论另一种类型的循环:for
循环。
for
循环
while
循环因为它需要三个部分来设置。首先看看下面的代码。我们将在以下内容之后将其分解:
for(int x = 0; x < 100; x ++)
{
// Something that needs to happen 100 times goes here
}
下面是for
循环条件中所有部分的作用:
for(; ; )
为了进一步阐明这一点,这里有一个表格来解释前一个for
循环示例中出现的三个关键部分:
我们可以修改for
循环,使其执行更多操作。下面是另一个简单的例子,它从 10 开始倒数:
for(int i = 10; i > 0; i--)
{
// countdown
}
// blast off
for
循环负责初始化、条件评估和控制变量的控制。我们将在本章后面使用for
循环来编写游戏。
现在,我们可以继续讨论 C++数组的话题,它可以帮助我们存储大量相关数据。
数组
如果一个变量是一个可以存储特定类型值的盒子,例如int
、float
或char
,那么我们可以将数组想象成一排盒子。这些盒子的行可以几乎任何大小和类型,包括由类创建的对象。然而,所有的盒子必须是同一类型的。
小贴士
一旦我们学习了更多高级的 C++,就可以在一定程度上绕过必须在每个盒子中使用相同类型的限制。
这个数组听起来可能对我们第二章中的云很有用,变量、运算符和决策——精灵动画。那么,我们如何创建和使用数组呢?
声明一个数组
我们可以声明一个int
类型的变量数组如下:
int someInts[10];
现在,我们有一个名为someInts
的数组,可以存储十个int
值。然而,目前它是空的。
初始化数组的元素
要向数组的元素中添加值,我们可以使用我们已熟悉的语法类型,同时引入一些新的语法,称为数组第一个元素中的99
:
someInts[0] = 99;
为了在第二个元素中存储值 999,我们需要使用以下代码:
someInts[1] = 999;
我们可以将值 3 存储在最后一个元素中,如下所示:
someInts[9] = 3;
注意,数组的元素始终从零开始,到数组大小减一。类似于普通变量,我们可以操作数组中存储的值。唯一的区别是我们会使用数组表示法来这样做,因为尽管我们的数组有一个名字——someInts
——但各个元素没有。
在以下代码中,我们将第一个和第二个元素相加,并将结果存储在第三个:
someInts[2] = someInts[0] + someInts[1];
数组也可以与普通变量无缝交互,例如:
int a = 9999;
someInts[4] = a;
我们可以以更多的方式初始化数组,现在让我们看看其中一种方法。
快速初始化数组的元素
我们可以快速向元素中添加值,如下所示。此示例使用一个float
数组:
float myFloatingPointArray[3] {3.14f, 1.63f, 99.0f};
现在,3.14
、1.63
和99.0
值分别存储在第一个、第二个和第三个位置。记住,当我们使用数组表示法来访问这些值时,我们会使用[0]、[1]和[2]。
初始化数组元素的其他方法。这个稍微抽象的例子展示了使用for
循环将值 0 到 9 放入uselessArray
数组中:
for(int i = 0; i < 10; i++)
{
uselessArray[i] = i;
}
上述代码假设uslessArray
之前已经被初始化,以存储至少 10 个int
变量。
但为什么我们需要数组?
这些数组对我们游戏到底有什么作用?
我们可以在任何可以使用普通变量的地方使用数组——例如在以下表达式中使用:
// someArray[4] is declared and initialized to 9999
for(int i = 0; i < someArray[4]; i++)
{
// Loop executes 9999 times
}
本节开头暗示了数组在游戏代码中的一个最大好处。数组可以存储对象(类的实例)。让我们想象我们有一个Zombie
类,我们想要存储一大堆。我们可以这样做:
Zombie horde [5] {zombie1, zombie2, zombie3}; // etc...
现在的horde
数组包含了大量的Zombie
类实例。每一个都是一个独立的、活着的(某种程度上)、呼吸的、自我决定的Zombie
对象。然后我们可以遍历horde
数组,每个对象都会通过游戏循环,移动僵尸,并检查它们的头部是否遇到了斧头,或者它们是否设法抓住了玩家。
如果我们当时就知道它们,数组将完美地用于处理我们的云朵,请参阅第二章,“变量、运算符和决策 – 动画精灵”。我们可以拥有我们想要的任意数量的云朵,并且编写的代码比我们为那三个可怜的云朵编写的代码要少。
小贴士
要查看改进后的云代码的完整内容和实际应用,请查看下载包中的 Timber!!!(代码和可玩游戏)的增强版本。或者,您可以在查看代码之前尝试自己使用数组实现云朵。
最好的方法是看到所有这些数组功能在实际中的应用。我们将在我们实现树分支时这样做。
现在,我们将保持我们的云代码不变,以便我们能够尽快回到添加游戏功能的工作中。但首先,让我们用switch做一些更多的 C++决策。
使用 switch 进行决策
我们已经看到了if
,它允许我们根据其表达式的结果决定是否执行代码块。但有时,C++中的决策可以通过其他更好的方式来实现。
当我们必须根据一个清晰的可能结果列表做出决策,而这些结果不涉及复杂的组合或广泛的值范围时,switch
通常是最佳选择。我们可以这样开始一个switch
决策:
switch(expression)
{
// More code here
}
在前面的例子中,expression
可以是实际的表达式,也可以只是一个变量。然后,在大括号内,我们可以根据表达式的结果或变量的值做出决策。我们使用case
和break
关键字来完成这项工作:
case x:
//code for x
break;
case y:
//code for y
break;
如您所见,每个case
都声明了一个可能的结果,每个break
都表示该case
的结束以及执行离开switch
块的时刻。
可选地,我们也可以使用不带值的default
关键字来运行一些代码,以防没有case
语句评估为true
,如下所示:
default: // Look no value
// Do something here if no other case statements are true
break;
作为switch
的最后一个和更具体的例子,考虑一个复古文字冒险游戏,玩家输入字母“n”、“e”、“s”或“w”来向北、向东、向南或向西移动。可以使用switch
块来处理玩家可能的每个输入:
// get input from user in a char called command
switch(command){
case 'n':
// Handle move here
break;
case 'e':
// Handle move here
break;
case 's':
// Handle move here
break;
case 'w':
// Handle move here
break;
// more possible cases
default:
// Ask the player to try again
break;
}
理解我们关于switch
所看到的一切的最佳方式是将它们付诸实践,同时结合我们正在学习的所有其他新概念。
接下来,我们将学习另一个在编写更多代码之前我们需要理解的 C++概念。让我们看看类枚举。
类枚举
枚举是一个逻辑集合中所有可能值的列表。C++枚举是列举事物的一种很好的方式。例如,如果我们的游戏使用的变量只能在一个特定的值范围内,并且这些值可以逻辑上形成一个集合或一组,那么枚举可能就是合适的。它们会使你的代码更清晰,更不容易出错。
在 C++中声明类枚举时,我们可以使用这两个关键字enum class
一起,后面跟着枚举的名称,然后是枚举可以包含的值,用一对花括号 {...}
括起来。
例如,检查以下枚举声明。请注意,按照惯例,枚举的可能值应该使用大写字母声明:
enum class zombieTypes {
REGULAR, RUNNER,
CRAWLER, SPITTER, BLOATER
};
注意,到目前为止,我们还没有声明zombieType
的任何实例,只是声明了类型本身。如果这听起来很奇怪,可以这样想。SFML 创建了Sprite
、RectangleShape
和RenderWindow
类,但为了使用这些类,我们必须声明一个对象/实例。
到目前为止,我们已经创建了一个名为zombieTypes
的新类型,但我们还没有它的实例。所以,让我们现在就做:
zombieType jeremy = zombieTypes::CRAWLER;
zombieType anna = zombieTypes::SPITTER;
zombieType diane = zombieTypes::BLOATER;
/*
Zombies are fictional creatures and any resemblance
to real people is entirely coincidental
*/
接下来是即将添加到 Timber!!!的代码类型的预览。我们希望跟踪树枝或玩家在树的哪一侧,因此我们将声明一个名为side
的枚举,如下所示:
enum class side { LEFT, RIGHT, NONE };
我们可以将玩家定位在左侧,如下所示:
// The player starts on the left
side playerSide = side::LEFT;
我们可以使树枝位置数组的第四级(数组从零开始)没有任何树枝,如下所示:
branchPositions[3] = side::NONE;
我们也可以在表达式中使用枚举:
if(branchPositions[5] == playerSide)
{
// The lowest branch is the same side as the player
// SQUISHED!!
}
以下代码测试数组位置[5]的分支是否与玩家在同一个方向上。
我们将探讨一个重要的 C++主题,那就是函数,然后我们将回到编写游戏代码。当我们想要将一些执行特定任务的代码封装起来时,我们可以使用函数。
开始学习函数
C++函数究竟是什么?函数是一系列变量、表达式和控制流语句(循环和分支)的集合。实际上,我们在本书中到目前为止所学的任何代码都可以用在函数中。我们编写的函数的第一部分被称为签名。以下是一个函数签名的示例:
void shootLazers(int power, int direction);
如果我们在函数执行的一些代码旁边添加一对开闭花括号 {...}
,我们就会得到一个完整的函数,即一个定义:
void shootLazers(int power, int direction)
{
// ZAPP!
}
然后,我们可以从代码的另一个部分使用我们新的函数,可能如下所示:
// Attack the player
shootLazers(50, 180) // Run the code in the function
// I'm back again - code continues here after the function ends
当我们使用一个函数时,我们说我们在“发射激光”,我们的程序执行分支到该函数内部包含的代码。函数将运行,直到它到达末尾或被指示“返回”。然后,代码将从函数调用后的第一行继续运行。我们已经在使用 SFML 提供的函数。这里的不同之处在于,我们将学习编写和调用我们自己的函数。
这里是另一个函数的示例,包括使函数返回到调用它的代码的代码:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
我们可以使用以下方式调用该函数:
int myAnswer = addAToB(2, 4);
显然,我们不需要编写函数来将两个变量相加,但这个例子帮助我们了解函数的工作原理。首先,我们传递值 2
和 4
。在函数签名中,值 2
被分配给 int a
,值 4
被分配给 int b
。
在函数体内部,变量 a
和 b
被相加,并用于初始化新变量 int answer
。return answer;
这一行正是这样做的。它将存储在 answer
中的值返回给调用代码,导致 myAnswer
被初始化为值 6
。
注意,前面示例中的每个函数签名都有所不同。这是因为 C++ 函数签名非常灵活,允许我们构建我们需要的精确函数。
函数签名如何定义函数必须如何调用以及函数必须如何返回值,这值得进一步讨论。让我们给这个签名的每一部分起个名字,这样我们就可以将其分解成部分并了解它们。
这里是一个带有其各部分通过其正式/技术术语描述的函数签名:
return type | name of function | (parameters)
这里有一些我们可以用于这些部分的示例:
-
void
,bool
,float
,int
等等,或者任何 C++ 类型或表达式 -
shootLazers
,addAToB
等等 -
(int number, bool hitDetected)
,(int x, int y)
,(float a, float b)
现在,让我们逐一查看每个部分,从返回类型开始。
函数返回类型
返回类型,正如其名称所暗示的,是函数将返回给调用代码的值的类型:
int addAToB(int a, int b){
int answer = a + b;
return answer;
}
在我们之前看过的稍微有点无聊但很有用的 addAtoB
示例中,签名中的返回类型是 int
。addAToB
函数将返回一个值,这个值将适合放入一个 int
变量中,返回给调用它的代码。返回类型可以是到目前为止我们看到的任何 C++ 类型,或者是我们还没有看到的类型之一。
函数根本不需要返回值。在这种情况下,签名必须使用 void
关键字作为返回类型。当使用 void
关键字时,函数体不得尝试返回值,因为这将导致错误。然而,它可以不带有值使用 return
关键字。以下是一些有效的返回类型和 return
关键字使用的组合:
void doWhatever(){
// our code
// I'm done going back to calling code here
// no return is necessary
}
另一种可能性如下:
void doSomethingCool(){
// our code
// I can do this if I don't try and use a value
return;
}
以下代码是更多可能的函数示例。务必阅读注释以及代码:
void doYetAnotherThing(){
// some code
if(someCondition){
// if someCondition is true returning to calling code
// before the end of the function body
return;
}
// More code that might or might not get executed
return;
// As I'm at the bottom of the function body
// and the return type is void, I'm
// really not necessary but I suppose I make it
// clear that the function is over.
}
bool detectCollision(Ship a, Ship b){
// Detect if collision has occurred
if(collision)
{
// Bam!!!
return true;
}
else
{
// Missed
return false;
}
}
在前面代码中的最后一个函数示例,即detectCollision
,是对我们 C++代码未来近期的预览,并展示了我们也可以将用户定义的类型,即对象,传递给函数,以便在它们上进行计算。
我们可以依次调用每个函数,如下所示:
// OK time to call some functions
doWhatever();
doSomethingCool();
doYetAnotherThing();
if (detectCollision(milleniumFalcon, lukesXWing))
{
// The jedi are doomed!
// But there is always Leia.
// Unless she was on the Falcon?
}
else
{
// Live to fight another day
}
// Continue with code from here
不要担心关于detectCollision
函数看起来奇怪的语法;我们很快就会看到这样的真实代码。简单来说,我们正在使用返回值(true
或false
)作为if
语句中的表达式。
函数名称
当我们设计自己的函数时,我们使用的函数名称几乎可以是任何东西。但最好使用单词,通常是动词,清楚地说明函数将做什么。例如,看看以下函数:
void functionaroonieboonie(int blibbityblob, float floppyfloatything)
{
//code here
}
前面的函数是完全合法的,并且会正常工作,但以下函数名称更清晰:
void doSomeVerySpecificTask()
{
//code here
}
int getMySpaceShipHealth()
{
//code here
}
void startNewGame()
{
//code here
}
使用像前面三个例子中那样的清晰和描述性的函数名称是一个好的做法,但正如我们从functionaroonieboonie
函数中看到的那样,这不是编译器强制执行的规定。接下来,我们将更仔细地看看我们如何与函数共享一些值。
函数参数
我们知道一个函数可以返回结果给调用代码。但如果我们需要从调用代码中共享一些数据值给函数呢?参数允许我们与函数共享值。在我们查看返回类型时,我们已经看到了参数的例子。我们将查看相同的例子,但更仔细一些:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
在这里,参数是int a
和int b
。注意,在函数主体的第一行,我们使用a + b
就像它们已经是声明并初始化的变量一样。嗯,那是因为它们确实是。函数签名中的参数是它们的声明,调用函数的代码初始化它们。
重要术语说明
注意,我们在这里指的是函数签名括号中的变量(int a, int b)
,我们称之为参数。当我们从调用代码向函数传递值时,这些值被称为参数。当参数到达时,它们被参数用来初始化真实、可用的变量,例如:
int returnedAnswer = addAToB(10,5);
此外,正如我们在前面的例子中部分看到的那样,我们不必在我们的参数中只使用int
。我们可以使用任何 C++类型。我们也可以使用尽可能多的参数来解决我们的问题,但保持参数列表尽可能短,因此尽可能易于管理是一个好的做法。
正如我们将在未来的章节中看到的那样,我们在这个入门教程中省略了一些函数的酷用,这样我们就可以在学习函数之前先了解相关的 C++概念。
函数主体
主体是我们一直避免的部分,其中包含如下注释:
// code here
// some code
实际上,我们在这里已经知道该做什么了!到目前为止我们所学的任何 C++代码都可以在函数体中使用。
函数原型
到目前为止,我们已经看到了如何编写函数以及如何调用函数。然而,我们还需要做一件事才能使它们正常工作。所有函数都必须有一个原型。原型是让编译器知道我们的函数,没有原型整个游戏将无法编译。幸运的是,原型很简单。
我们可以简单地重复函数的签名,然后跟一个分号。需要注意的是,原型必须在尝试调用或定义函数之前出现。因此,一个完全可用的函数的最简单示例如下。仔细查看注释和代码中函数不同部分出现的位置:
// The prototype
// Notice the semicolon on the end
int addAToB(int a, int b);
int main()
{
// Call the function
// Store the result in answer
int answer = addAToB(2,2);
// Called before the definition
// but that's OK because of the prototype
// Exit main
return 0;
}// End of main
// The function definition
int addAToB(int a, int b)
{
return a + b;
}
之前代码展示的是以下内容:
-
原型位于
main
函数之前。 -
使用函数的调用正如我们所预期的那样,在
main
函数内部。 -
定义位于
main
函数之后/外部。重要提示
注意,当函数的定义出现在使用函数之前时,我们可以省略函数原型并直接进入定义。然而,随着我们的代码变得越来越长,并分散到多个文件中,这种情况几乎不会发生。我们将会一直使用单独的原型和定义。
让我们看看我们如何保持函数的有序性。
组织函数
值得指出的是,如果我们有多个函数,尤其是如果它们相当长,我们的.cpp
文件将很快变得难以管理。这违背了函数旨在实现的部分目标。在下一个项目中,我们将看到解决方案,即我们可以将所有函数原型添加到我们自己的头文件(.hpp
或.h
)中。然后,我们可以在另一个.cpp
文件中编写所有函数,并在我们的主.cpp
文件中简单地添加另一个#include...
指令。这样,我们可以使用任意数量的函数,而无需将它们的代码(原型或定义)添加到主代码文件中。
函数陷阱!
我们还应该讨论的关于函数的另一个点是作用域。如果我们在一个函数中声明一个变量,无论是直接声明还是在参数中声明,那么这个变量在该函数外部不可用/不可见。此外,在函数内部声明的任何变量在函数内部也看不见/不可用。
我们应该在函数代码和调用代码之间通过参数/参数和返回值来共享值。
当一个变量不可用,因为它来自另一个函数时,我们说它超出了作用域。当它可用且可使用时,我们说它在作用域内。
重要提示
在 C++中,任何块内声明的变量仅在該块的作用域内有效!这包括循环和if
块。在main
函数顶部声明的变量在main
函数的任何地方都有作用域,在游戏循环中声明的变量仅在游戏循环中有作用域,依此类推。在函数或其他块内声明的变量称为局部变量。我们编写的代码越多,这一点就越有意义。每次我们在代码中遇到关于作用域的问题时,我都会讨论它,以便让大家明白。下一节将出现这样一个问题。还有一些其他的 C++基本概念会使这个问题更加突出。它们被称为引用和指针,我们将在第九章,“C++引用、精灵图集和顶点数组”和第十章,“指针、标准模板库和纹理管理”中分别学习它们。
更多关于函数的内容
我们甚至可以学习更多关于函数的知识,但我们已经足够了解它们,可以实施游戏下一部分的功能。而且,如果所有像参数、签名和定义这样的技术术语还没有完全理解,请不要担心。当我们开始使用这些概念时,它们会变得更加清晰。
关于函数的绝对最终话——现在
可能你没有注意到,我们一直在通过在函数名之前添加对象名称和一个点来调用函数,特别是 SFML 函数,如下所示:
spriteBee.setPosition...
window.draw...
// etc
尽管如此,我们关于函数的整个讨论都是关于调用没有对象的函数。我们可以将函数作为类的一部分编写,或者简单地作为一个独立的函数编写。当我们将函数作为类的一部分编写时,我们需要该类的对象来调用该函数,但是当我们有一个独立的函数时,我们不需要。
我们将在一分钟内编写一个独立的函数,并从第六章,“面向对象编程 - 开始 Pong 游戏”开始编写带有函数的类。到目前为止,我们所了解到的关于函数的一切在两种情况下都是相关的。
现在,我们可以回到编写 Timber!!!游戏中的分支代码。
分支的生长
接下来,正如我在过去 20 页中承诺的那样,我们将使用我们学到的所有新的 C++技术来绘制和移动我们树上的一些分支。
在main
函数外部添加以下代码。为了绝对清楚,我的意思是在int main()
代码之前:
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
// Function declaration
void updateBranches(int seed);
const int NUM_BRANCHES = 6;
Sprite branches[NUM_BRANCHES];
// Where is the player/branch?
// Left or Right
enum class side { LEFT, RIGHT, NONE };
side branchPositions[NUM_BRANCHES];
int main()
我们用新代码实现了相当多的事情:
-
首先,我们为名为
updateBranches
的函数编写了一个函数原型。我们可以看到它不返回值(void
),并且接受一个名为seed
的int
参数。我们很快将编写函数定义,然后我们将看到它确切地做了什么。 -
接下来,我们声明一个名为
NUM_BRANCHES
的int
常量并将其初始化为6
。树上将会有六个移动的分支,我们很快就会看到NUM_BRANCHES
如何对我们有所帮助。 -
在此之后,我们声明了一个名为
branches
的Sprite
对象数组,它可以容纳六个Sprite
实例。 -
之后,我们声明了一个名为
side
的新枚举,有三个可能的值:LEFT
、RIGHT
和NONE
。这将被用来描述代码中几个地方单个分支以及玩家的位置。 -
最后,在前面提到的代码中,我们初始化了一个大小为
NUM_BRANCHES
(6)的side
类型数组。为了清楚地了解这实现了什么,我们将有一个包含六个值的数组,名为branchPositions
。这些值都是side
类型,可以是LEFT
、RIGHT
或NONE
。重要提示
当然,你真正想知道的是为什么常量、两个数组和枚举被声明在
main
函数的外部。通过在main
之上声明它们,现在它们对main
函数和updateBranches
函数都是可见的。请注意,将所有变量尽可能本地化到它们实际使用的位置是一种良好的实践。虽然将所有内容都设置为全局可能看起来很有用,但这会导致难以阅读和容易出错的代码。
准备分支
现在,我们将准备我们的六个 Sprite
对象并将它们加载到 branches
数组中。在我们的游戏循环之前添加以下突出显示的代码:
// Position the text
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
// Prepare 6 branches
Texture textureBranch;
textureBranch.loadFromFile("graphics/branch.png");
// Set the texture for each branch sprite
for (int i = 0; i < NUM_BRANCHES; i++) {
branches[i].setTexture(textureBranch);
branches[i].setPosition(-2000, -2000);
// Set the sprite's origin to dead centre
// We can then spin it round without changing its position
branches[i].setOrigin(220, 20);
}
while (window.isOpen())
在前面的代码中,我们正在执行以下操作:
-
首先,我们声明一个 SFML
Texture
对象并将branch.png
图形加载到其中。 -
接下来,我们创建一个
for
循环,将i
设置为零,并在每次循环中递增i
一次,直到i
不再小于NUM_BRANCHES
。这正是正确的,因为NUM_BRANCHES
是6
,而branches
数组有位置0
到5
。 -
在
for
循环内部,我们使用setTexture
为branches
数组中的每个Sprite
设置Texture
,然后使用setPosition
将其隐藏在屏幕之外。 -
最后,我们使用
setOrigin
将精灵的原点(用于在绘制时定位精灵的点)设置为精灵的中心。很快,我们将旋转这些精灵。将原点放在中心意味着它们将很好地旋转,而不会将精灵移出位置。
现在我们已经准备好了所有分支,我们可以编写一些代码来在每一帧更新它们。
每帧更新分支精灵
在下面的代码中,我们将根据它们在数组中的位置和 branchPositions
数组中相应的 side
值设置 branches
数组中所有精灵的位置。添加以下突出显示的代码,并在我们详细解释之前先尝试理解它:
// Update the score text
std::stringstream ss;
ss << "Score: " << score;
scoreText.setString(ss.str());
// update the branch sprites
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Move the sprite to the left side
branches[i].setPosition(610, height);
// Flip the sprite round the other way
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Move the sprite to the right side
branches[i].setPosition(1330, height);
// Set the sprite rotation to normal
branches[i].setRotation(0);
}
else
{
// Hide the branch
branches[i].setPosition(3000, height);
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
我们刚刚添加的代码是一个大型的 for
循环,它将 i
设置为零,并在每次循环中递增 i
一次,直到 i
不再小于 6
。
在for
循环内部,一个新的float
变量height
被设置为i * 150
。这意味着第一个分支的高度为 0,第二个为 150,第六个为 750。
接下来,我们有一个if
和else
代码块的结构。看看去掉代码后的结构:
if()
{
}
else if()
{
}
else
{
}
第一个if
语句使用branchPositions
数组来判断当前分支是否应该在左侧。如果是,它将branches
数组中对应的Sprite
设置到屏幕上的一个位置,适合左侧(610 像素)和当前的height
。然后通过 180 度翻转精灵,因为branch.png
图形默认是向右悬挂的。
注意,只有当分支不在左侧时,else if
才会执行。它使用相同的方法来判断分支是否在右侧。如果是,则分支将在右侧(1,330 像素)绘制。然后,将精灵旋转设置为 0 度,以防它之前是 180 度。如果 x 坐标看起来有点奇怪,只需记住我们已将分支精灵的起点设置为它们的中心。
最后的else
语句正确地假设当前的branchPosition
必须是NONE
,并将分支隐藏在屏幕外的 3,000 像素处。
到目前为止,我们的分支已经定位好,准备绘制。
绘制分支
这里,我们将使用另一个for
循环来遍历整个branches
数组,从 0 到 5,并绘制每个分支精灵。添加以下高亮代码:
// Draw the clouds
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Draw the branches
for (int i = 0; i < NUM_BRANCHES; i++) {
window.draw(branches[i]);
}
// Draw the tree
window.draw(spriteTree);
当然,我们还没有编写移动所有分支的函数。一旦我们编写了这个函数,我们还需要确定何时以及如何调用它。让我们先解决第一个问题并编写这个函数。
移动分支
我们已经在上面的main
函数上方添加了函数原型。现在,我们可以编写函数的实际定义,该函数每次被调用时都会将所有分支向下移动一个位置。我们将分两部分编写这个函数,以便我们可以轻松检查正在发生的事情。
在main
函数的闭合花括号之后添加updateBranches
函数的第一部分:
// Function definition
void updateBranches(int seed)
{
// Move all the branches down one place
for (int j = NUM_BRANCHES-1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
}
在函数的这一部分,我们只是将所有分支逐个向下移动一个位置,从第六个分支开始。这是通过使for
循环从 5 计数到 0 来实现的。注意branchPositions[j] = branchPositions[j - 1];
实际上实现了移动。
在此之前的代码中需要注意的另一件事是,在将位置 4 的分支移动到位置 5,然后将位置 3 的分支移动到位置 4,依此类推之后,我们需要在位置 0 添加一个新的分支,这是树的顶部。
现在,我们可以在树的顶部生成一个新的分支。添加以下高亮代码,然后我们将讨论它:
// Function definition
void updateBranches(int seed)
{
// Move all the branches down one place
for (int j = NUM_BRANCHES-1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
// Spawn a new branch at position 0
// LEFT, RIGHT or NONE
srand((int)time(0)+seed);
int r = (rand() % 5);
switch (r) {
case 0:
branchPositions[0] = side::LEFT;
break;
case 1:
branchPositions[0] = side::RIGHT;
break;
default:
branchPositions[0] = side::NONE;
break;
}
}
在 updateBranches
函数的最后部分,我们使用传递给函数调用的整数 seed
变量。我们这样做是为了确保随机数种子总是不同的。我们将在下一章看到我们是如何得到这个值的。
接下来,我们在零和四之间生成一个随机数,并将结果存储在名为 r
的 int
变量中。现在,我们使用 r
作为表达式进行 switch
。
case
语句意味着如果 r
等于零,那么我们在树的左侧顶部添加一个新的分支。如果 r
等于 1,那么分支将向右延伸。如果 r
是其他任何值(2、3 或 4),那么 default
确保不会在顶部添加任何分支。这种左、右和没有分支的平衡使树看起来更真实,游戏运行得相当好。你可以轻松地更改代码,使分支更频繁或更少。
即使在所有这些分支代码之后,我们仍然在游戏中看不到任何一个分支。这是因为在我们能够调用 updateBranches
函数之前,我们还有更多的工作要做。
如果你现在想看到分支,你可以添加一些临时代码,并在游戏循环之前,每次使用一个独特的种子调用该函数五次:
updateBranches(1);
updateBranches(2);
updateBranches(3);
updateBranches(4);
updateBranches(5);
while (window.isOpen())
{
你现在可以看到分支的位置了。但如果分支要真正移动,我们就需要定期调用 updateBranches
:
小贴士
在继续之前,不要忘记删除临时代码。
现在,我们也可以将注意力转向玩家,因为真正调用 updateBranches
函数。我们将在下一章这样做。
摘要
虽然不是最长的章节,但可能是我们迄今为止涵盖的 C++ 内容最多的章节。我们研究了我们可以使用的不同类型的循环,例如 for
和 while
循环。然后我们研究了数组,我们可以使用它们来处理大量的变量和对象,而不会感到吃力。我们还学习了枚举和 switch
。本章最大的概念可能是函数,它允许我们组织和抽象我们游戏的代码。我们将在本书的几个地方更深入地研究函数。
现在我们已经有一个完全“工作”的树了,我们可以完成游戏,我们将在下一章和这个项目的最后一章中这样做。
常见问题解答
Q) 你提到还有更多种类的 C++ 循环。我可以在哪里找到关于它们的信息?
A) 是的,查看这个关于 do while
循环的教程和解释:www.tutorialspoint.com/cplusplus/cpp_do_while_loop.htm
。
Q) 我现在可以假设自己是数组方面的专家吗?
A) 就像本书中的许多主题一样,总有更多东西可以学习。你关于数组的知识已经足够继续前进,但如果你渴望更多,请查看这个更全面的数组教程:www.cplusplus.com/doc/tutorial/arrays/
。
Q) 我可以假设自己是函数方面的专家吗?
A) 与本书中的许多主题一样,总有更多东西可以学习。你对函数的了解已经足够继续前进,但如果你还想了解更多,请查看这个教程:www.cplusplus.com/doc/tutorial/functions/
.
第六章:第五章:碰撞、音效和结束条件 – 使游戏可玩
这是第一个项目的最后阶段。到本章结束时,你将拥有你的第一个完成的游戏。一旦你让 Timber!!! 运行起来,务必阅读本章的最后部分,因为它将建议如何使游戏变得更好。
在本章中,我们将涵盖以下主题:
-
添加其余的精灵
-
处理玩家输入
-
动画飞行的木材
-
处理死亡
-
添加音效
-
添加功能和改进 Timber!!!
准备玩家(和其他精灵)
让我们同时添加玩家精灵以及一些更多精灵和纹理的代码。以下相当大的代码块还添加了一个用于玩家被压扁时的墓碑精灵,一个用于砍伐的斧头精灵,以及一个每次玩家砍伐时都会飞走的木材精灵。
注意,在 spritePlayer
对象之后,我们声明了一个 side
变量,playerSide
,以跟踪玩家当前站立的位置。此外,我们还为 spriteLog
对象添加了一些额外的变量,包括 logSpeedX
、logSpeedY
和 logActive
,以存储木材的移动速度以及它是否正在移动。spriteAxe
还有两个相关的 float
常量变量,用于记住左右两侧理想的像素位置。
在 while(window.isOpen())
代码之前添加以下代码块,就像我们之前经常做的那样。请注意,以下代码块中的所有代码都是新的,而不仅仅是突出显示的代码。我没有为这个代码块提供任何额外的上下文,因为 while(window.isOpen())
应该很容易识别。突出显示的代码是我们刚刚讨论过的代码。
在 while(window.isOpen())
行之前添加以下代码的全部内容,并在心中记住我们简要讨论过的突出显示的行。这将使本章其余的代码更容易理解:
// Prepare the player
Texture texturePlayer;
texturePlayer.loadFromFile("graphics/player.png");
Sprite spritePlayer;
spritePlayer.setTexture(texturePlayer);
spritePlayer.setPosition(580, 720);
// The player starts on the left
side playerSide = side::LEFT;
// Prepare the gravestone
Texture textureRIP;
textureRIP.loadFromFile("graphics/rip.png");
Sprite spriteRIP;
spriteRIP.setTexture(textureRIP);
spriteRIP.setPosition(600, 860);
// Prepare the axe
Texture textureAxe;
textureAxe.loadFromFile("graphics/axe.png");
Sprite spriteAxe;
spriteAxe.setTexture(textureAxe);
spriteAxe.setPosition(700, 830);
// Line the axe up with the tree
const float AXE_POSITION_LEFT = 700;
const float AXE_POSITION_RIGHT = 1075;
// Prepare the flying log
Texture textureLog;
textureLog.loadFromFile("graphics/log.png");
Sprite spriteLog;
spriteLog.setTexture(textureLog);
spriteLog.setPosition(810, 720);
// Some other useful log related variables
bool logActive = false;
float logSpeedX = 1000;
float logSpeedY = -1500;
在前面的代码中,我们添加了许多新的变量。在真正使用它们之前,很难完全解释它们,但这里简要概述一下它们将用于什么。有一个名为 playerSide
的 side
枚举类型变量,初始化为 left
。这将跟踪玩家位于树的哪一侧。
有两个 const float
类型的值用于确定斧头将被绘制在水平位置,这取决于玩家是否位于树的左侧或右侧。
除了帮助控制被砍伐并飞离树木的木材外,还有三个变量,bool
类型的 logActive
用于确定木材是否在运动,以及两个 float
类型的值用于存储木材的水平速度和垂直速度。
现在,我们可以绘制所有新的精灵。
绘制玩家和其他精灵
在我们添加移动玩家和使用所有新精灵的代码之前,让我们先绘制它们。我们这样做是为了当我们添加代码来更新/更改/移动它们时,我们能够看到正在发生的事情。
添加以下突出显示的代码以绘制四个新的精灵:
// Draw the tree
window.draw(spriteTree);
// Draw the player
window.draw(spritePlayer);
// Draw the axe
window.draw(spriteAxe);
// Draw the flying log
window.draw(spriteLog);
// Draw the gravestone
window.draw(spriteRIP);
// Draw the bee
window.draw(spriteBee);
上述代码将四个新精灵依次传递给draw
函数。
运行游戏,你将看到场景中的新精灵:
我们现在离一个可工作的游戏非常近了。下一个任务是编写一些代码,让玩家能够控制发生的事情。
处理玩家的输入
玩家的移动会影响几个不同的事情,如下所示:
-
何时显示斧头
-
何时开始动画木头
-
何时移动所有树枝向下
因此,为正在砍伐的玩家设置键盘处理是有意义的。一旦完成这项工作,我们就可以将我们刚才提到的所有功能放入代码的同一部分。
让我们暂时思考一下我们是如何检测键盘按键的。每一帧,我们都会测试是否有特定的键盘键被按下。如果是,我们就采取行动。如果Esc键被按下,我们就退出游戏,如果Enter键被按下,我们就重新开始游戏。到目前为止,这已经足够满足我们的需求了。
然而,当我们尝试处理树木的砍伐时,这种方法存在一个问题。这个问题一直存在;只是直到现在才变得重要。根据你的 PC 性能如何,游戏循环每秒可能执行数千次。每次通过游戏循环,只要按键被按下,就会检测到,并执行相关代码。
所以,实际上,每次你按下Enter键重新开始游戏,你很可能已经重新开始了一百多次以上。这是因为即使是最短暂的按键也会持续一秒钟的很大一部分。你可以通过运行游戏并按住Enter键来验证这一点。请注意,时间条不会移动。这是因为游戏正在一次又一次地重新开始,每秒数百次甚至数千次。
如果我们不使用不同的方法来处理玩家的砍伐,那么仅仅一次尝试砍伐就会在短短的一瞬间将整棵树砍倒。我们需要更加复杂一些。我们将允许玩家砍伐,然后当玩家这样做时,禁用检测按键的代码。然后我们将检测玩家何时从按键上移开手指,然后重新启用按键检测。以下是将这些步骤清晰地列出的步骤:
-
等待玩家使用左右箭头键砍伐一根木头。
-
当玩家砍伐时,禁用按键检测。
-
等待玩家从按键上移开手指。
-
重新启用砍伐检测。
-
从步骤 1 重复。
这可能听起来很复杂,但有了 SFML 的帮助,这将变得简单直接。让我们现在一步一步地实现它。
添加以下突出显示的代码行,它声明了一个名为acceptInput
的bool
变量,该变量将用于确定何时监听砍伐,何时忽略它们:
float logSpeedX = 1000;
float logSpeedY = -1500;
// Control the player input
bool acceptInput = false;
while (window.isOpen())
{
现在我们已经设置了布尔值,我们可以继续下一步。
处理设置新游戏
为了准备好处理砍伐,将以下突出显示的代码添加到开始新游戏的if
块中:
/*
****************************************
Handle the players input
****************************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 6;
// Make all the branches disappear -
// starting in the second position
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Make sure the gravestone is hidden
spriteRIP.setPosition(675, 2000);
// Move the player into position
spritePlayer.setPosition(580, 720);
acceptInput = true;
}
/*
****************************************
Update the scene
****************************************
*/
在之前的代码中,我们使用一个for
循环来准备没有分支的树。这对玩家是公平的,因为如果游戏从他们头顶上方的一个分支开始,那将被视为不公平。然后,我们简单地将墓碑移出屏幕,并将玩家移动到左侧的起始位置。前述代码所做的最后一件事是将acceptInput
设置为true
。
我们现在准备好接收砍伐按键。
检测玩家砍伐
现在,我们可以处理左右光标键的按下。添加以下简单的if
块,它仅在acceptInput
为true
时执行:
// Start the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Reset the time and the score
score = 0;
timeRemaining = 5;
// Make all the branches disappear
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Make sure the gravestone is hidden
spriteRIP.setPosition(675, 2000);
// Move the player into position
spritePlayer.setPosition(675, 660);
acceptInput = true;
}
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
}
/*
****************************************
Update the scene
****************************************
*/
现在,在刚刚编写的if
块内部,添加以下突出显示的代码来处理当玩家按下键盘上的右光标键时会发生什么:
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
// First handle pressing the right cursor key
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Make sure the player is on the right
playerSide = side::RIGHT;
score ++;
// Add to the amount of time remaining
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1200, 720);
// Update the branches
updateBranches(score);
// Set the log flying to the left
spriteLog.setPosition(810, 720);
logSpeedX = -5000;
logActive = true;
acceptInput = false;
}
// Handle the left cursor key
}
在前述代码中发生了很多事情,让我们逐一分析:
-
首先,我们检测玩家是否在树的右侧进行了砍伐。如果是,则将
playerSide
设置为side::RIGHT
。我们将在代码的后续部分响应playerSide
的值。然后,使用score ++
给分数加一分。 -
下一行代码有点神秘,但所发生的一切只是我们在剩余时间上增加。我们在奖励玩家采取行动。然而,对玩家来说,分数越高,额外增加的时间就越少。你可以调整这个公式来使游戏更容易或更难。
-
然后,使用
spriteAxe.setPosition
将斧头移动到其右侧位置,并将玩家精灵也移动到其右侧位置。 -
接下来,我们调用
updateBranches
来将所有分支向下移动一个位置,并在树的顶部生成一个新的随机分支(或空间)。 -
然后,将
spriteLog
移动到其起始位置,使其与树木融为一体,并将其speedX
变量设置为负数,以便它向左飞驰。同时,将logActive
设置为true
,以便我们即将编写的移动木头的代码在每一帧中动画化木头。 -
最后,将
acceptInput
设置为false
。此时,玩家不能再进行砍伐。我们已经解决了按键检测过于频繁的问题,我们很快将看到如何重新启用砍伐。
现在,仍然在刚刚编写的if(acceptInput)
块内部,添加以下突出显示的代码来处理当玩家按下键盘上的左光标键时会发生什么:
// Handle the left cursor key
if (Keyboard::isKeyPressed(Keyboard::Left))
{
// Make sure the player is on the left
playerSide = side::LEFT;
score++;
// Add to the amount of time remaining
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_LEFT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(580, 720);
// update the branches
updateBranches(score);
// set the log flying
spriteLog.setPosition(810, 720);
logSpeedX = 5000;
logActive = true;
acceptInput = false;
}
}
之前的代码与处理右侧切割的代码相同,只是精灵的位置不同,并且将logSpeedX
变量设置为正值,这样木材就会向右飞去。
现在,我们可以编写当键盘按键释放时发生的事情的代码。
检测按键释放
要使前面的代码在第一次切割之后仍然有效,我们需要检测玩家何时释放按键,然后将acceptInput
重置为true
。
这与之前我们看到的关键字处理方式略有不同。SFML 有两种不同的方式来检测玩家的键盘输入。我们已经在前处理Enter键时看到了第一种方式,它是动态的和即时的,这正是我们立即对按键做出响应所需要的。
以下代码使用检测按键释放的方法。在Handle the players input
部分的顶部输入以下高亮代码,然后我们将对其进行讲解:
/*
****************************************
Handle the players input
****************************************
*/
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyReleased && !paused)
{
// Listen for key presses again
acceptInput = true;
// hide the axe
spriteAxe.setPosition(2000,
spriteAxe.getPosition().y);
}
}
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
在前面的代码中,我们声明了一个名为event
的Event
类型对象。然后,我们调用window.pollEvent
函数,传入我们新创建的对象event
。pollEvent
函数将描述操作系统事件的 数据放入event
对象中。这可能是按键、按键释放、鼠标移动、鼠标点击、游戏控制器动作,或者是窗口本身发生的事情(如大小调整、移动等)。
我们将代码包裹在while
循环中的原因是因为队列中可能存储了多个事件。window.pollEvent
函数会逐个将它们加载到event
中。随着每次循环的进行,我们会检查是否对当前事件感兴趣,并在感兴趣时做出响应。当window.pollEvent
返回false
时,这意味着队列中没有更多事件,while
循环将退出。
当一个键被释放且游戏未暂停时,执行此if
条件(event.type == Event::KeyReleased && !paused)
。
在if
块内部,我们将acceptInput
重置为true
,并将斧头精灵隐藏到屏幕之外。
你现在可以运行游戏,并敬畏地注视着移动的树木、挥舞的斧头和动画化的玩家。然而,它不会压扁玩家,而且当切割时,木材还没有移动。
让我们继续制作日志移动效果。
动画切割的木材和斧头
当玩家切割时,logActive
被设置为true
,因此我们可以将一些代码包裹在一个只有当logActive
为true
时才执行的代码块中。此外,每次切割都会将logSpeedX
设置为正数或负数,这样木材就可以在正确的方向上开始从树木飞离。
在更新分支精灵之后,添加以下高亮代码:
// update the branch sprites
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Move the sprite to the left side
branches[i].setPosition(610, height);
// Flip the sprite round the other way
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Move the sprite to the right side
branches[i].setPosition(1330, height);
// Flip the sprite round the other way
branches[i].setRotation(0);
}
else
{
// Hide the branch
branches[i].setPosition(3000, height);
}
}
// Handle a flying log
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x +
(logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y +
(logSpeedY * dt.asSeconds()));
// Has the log reached the right hand edge?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Set it up ready to be a whole new log next frame
logActive = false;
spriteLog.setPosition(810, 720);
}
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
代码通过使用getPosition
获取精灵的当前水平和垂直位置,然后分别使用logSpeedX
和logSpeedY
以及dt.asSeconds
的乘积来添加,从而设置精灵的位置。
在每个帧移动日志精灵之后,代码使用一个if
块来检查精灵是否已经从左侧或右侧消失在视野之外。如果是这样,日志就会移回到起始点,为下一次切割做好准备。
如果你现在运行游戏,你将能够看到日志飞向屏幕的适当一侧:
现在,让我们转向一个更敏感的话题。
处理死亡
每个游戏都必须以糟糕的方式结束,要么是玩家用完时间(这我们已经处理过了),要么是被树枝压扁。
检测玩家被压扁非常简单。我们只想知道:branchPositions
数组中的最后一个树枝位置是否等于playerSide
?如果是,玩家就死了。
添加以下高亮代码,用于检测和执行玩家被树枝压扁的情况。我们稍后会讨论它:
// Handle a flying log
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x +
(logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y +
(logSpeedY * dt.asSeconds()));
// Has the log reached the right-hand edge?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Set it up ready to be a whole new cloud next frame
logActive = false;
spriteLog.setPosition(800, 600);
}
}
// has the player been squished by a branch?
if (branchPositions[5] == playerSide)
{
// death
paused = true;
acceptInput = false;
// Draw the gravestone
spriteRIP.setPosition(525, 760);
// hide the player
spritePlayer.setPosition(2000, 660);
// Change the text of the message
messageText.setString("SQUISHED!!");
// Center it on the screen
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f,
1080 / 2.0f);
}
} // End if(!paused)
/*
****************************************
Draw the scene
****************************************
*/
在玩家死亡之后,前面的代码首先将paused
设置为true
。现在,循环将完成这一帧,并且不会再次运行循环的更新部分,直到玩家开始新的一局。
然后,我们将墓碑移动到位置,靠近玩家曾经站立的地方,并将玩家精灵隐藏在屏幕之外。
我们将messageText
字符串设置为"Squished!!"
,然后使用常规技术将其居中显示在屏幕上。
你现在可以运行游戏并真正地玩一玩。以下截图显示了玩家的最终得分和他们的墓碑,以及SQUISHED信息:
只有一个问题需要解决。是我一个人这样觉得吗,还是它有点安静?
简单的声音效果
在本节中,我们将添加三个声音。每个声音将在特定的游戏事件上播放,即玩家每次切割时发出简单的砰砰声,玩家用完时间时发出忧郁的失败声,以及玩家被压扁至死时的复古压碎声。
SFML 声音是如何工作的
SFML 使用两个不同的类来播放声音效果。第一个类是SoundBuffer
类。这是一个包含实际音频数据的类,它负责将.wav
文件加载到 PC 的 RAM 中,以可以播放的格式,无需进一步解码工作。
当我们编写下一分钟的声音效果代码时,我们会看到,一旦我们有一个存储了声音的SoundBuffer
对象,我们就会创建另一个Sound
类型的对象。然后,我们可以将这个Sound
对象与SoundBuffer
对象关联起来。然后,在代码的适当时刻,我们将能够调用相应Sound
对象的play
函数。
何时播放声音
正如我们很快就会看到的,加载和播放声音的 C++代码非常简单。然而,我们需要考虑的是何时调用play
函数,在哪里放置play
函数的调用?让我们看看:
-
切割声音可以通过左右光标键的按键来调用。
-
可以从检测到树木弄伤玩家的
if
块中播放死亡声音。 -
可以从检测
timeRemaining
小于零的if
块中播放超时声音。
现在,我们可以编写我们的声音代码了。
添加声音代码
首先,我们将添加另一个 #include
指令,以便使 SFML 声音相关类可用。添加以下突出显示的代码:
#include <sstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
using namespace sf;
现在,我们将声明三个不同的 SoundBuffer
对象,将三个不同的声音文件加载到它们中,并将三个不同的 Sound
类型的对象与相关的 SoundBuffer
类型的对象关联起来。添加以下突出显示的代码:
// Control the player input
bool acceptInput = false;
// Prepare the sounds
// The player chopping sound
SoundBuffer chopBuffer;
chopBuffer.loadFromFile("sound/chop.wav");
Sound chop;
chop.setBuffer(chopBuffer);
// The player has met his end under a branch
SoundBuffer deathBuffer;
deathBuffer.loadFromFile("sound/death.wav");
Sound death;
death.setBuffer(deathBuffer);
// Out of time
SoundBuffer ootBuffer;
ootBuffer.loadFromFile("sound/out_of_time.wav");
Sound outOfTime;
outOfTime.setBuffer(ootBuffer);
while (window.isOpen())
{
现在,我们可以播放第一个音效了。将以下单行代码添加到检测玩家按下右光标键的 if
块中:
// Wrap the player controls to
// Make sure we are accepting input
if (acceptInput)
{
// More code here next...
// First handle pressing the right cursor key
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Make sure the player is on the right
playerSide = side::RIGHT;
score++;
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1120, 660);
// update the branches
updateBranches(score);
// set the log flying to the left
spriteLog.setPosition(800, 600);
logSpeedX = -5000;
logActive = true;
acceptInput = false;
// Play a chop sound
chop.play();
}
小贴士
在以 if (Keyboard::isKeyPressed(Keyboard::Left))
开头的下一块代码的末尾添加完全相同的代码,以便当玩家在树的左侧砍伐时发出砍伐声。
找到处理玩家耗尽时间的代码,并将以下突出显示的代码添加到播放超时相关声音效果的代码中:
if (timeRemaining <= 0.f) {
// Pause the game
paused = true;
// Change the message shown to the player
messageText.setString("Out of time!!");
//Reposition the text based on its new size
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Play the out of time sound
outOfTime.play();
}
最后,当玩家被挤压时播放死亡声音,将以下突出显示的代码添加到执行时底部树枝与玩家在同一侧的 if
块中:
// has the player been squished by a branch?
if (branchPositions[5] == playerSide)
{
// death
paused = true;
acceptInput = false;
// Draw the gravestone
spriteRIP.setPosition(675, 660);
// hide the player
spritePlayer.setPosition(2000, 660);
messageText.setString("SQUISHED!!");
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Play the death sound
death.play();
}
就这样!我们完成了第一个游戏。在我们继续进行第二个项目之前,让我们讨论一些可能的改进。
改进游戏和代码
查看这些针对 Timber!!! 项目的建议增强功能。你可以在下载包的 Runnable
文件夹中看到这些增强功能的效果:
-
在仅偶尔执行的代码块中的
sstream
代码。毕竟,我们不需要每秒更新分数数千次! -
调试控制台: 让我们添加一些文本,以便我们可以看到当前的帧率。就像分数一样,我们不需要太频繁地更新它。每百帧更新一次即可。
-
在背景中添加更多树木: 简单地添加一些更多的树木精灵,并将它们绘制在看起来好的位置(一些靠近相机,一些远离)。
-
在分数和 FPS 计数器后面的
RectangleShape
对象。黑色带有一点透明度看起来相当不错。 -
使云代码更高效: 正如我们之前多次提到的,我们可以利用我们对数组的了解来使云代码变得更短。
查看带有额外树木、云彩和透明背景文本的游戏动作:
要查看这些增强功能的代码,请查看下载包的 Timber Enhanced Version
文件夹。
摘要
在本章中,我们为 Timber!!! 游戏添加了最后的修饰和图形。如果你在本书之前从未编写过任何 C++ 代码,那么你可以给自己一个大大的掌声。仅仅五章,你就从零知识到了一个可工作的游戏。
然而,我们不会因此而过于自满,因为在下一章中,我们将直接进入一些稍微更复杂的 C++ 内容。虽然下一款游戏,一个简单的乒乓球游戏,在某些方面比 Timber!! 简单,但了解如何编写自己的类将为我们构建更复杂和功能更齐全的游戏做好准备。
常见问题解答
Q) 我承认对于云的数组解决方案更有效率。但我们真的需要三个独立的数组——一个用于 active
,一个用于速度,以及一个用于精灵本身吗?
A) 如果我们观察各种对象所具有的属性/变量,例如,Sprite
对象,我们会发现它们有很多。精灵具有位置、颜色、大小、旋转等属性。但如果它们有 active
、speed
以及可能的一些其他属性那就更完美了。问题是 SFML 的编码者不可能预测到我们会以所有可能的方式使用他们的 Sprite
类。幸运的是,我们可以创建自己的类。我们可以创建一个名为 Cloud
的类,它有一个布尔值用于 active
和一个整型用于速度。我们甚至可以给我们的 Cloud
类一个 SFML Sprite
对象。这样我们甚至可以进一步简化我们的云代码。我们将在下一章中探讨如何设计自己的类。
第七章:第六章:面向对象编程 – 开始 Pong 游戏
在本章中,有相当多的理论,但理论将为我们提供我们开始使用面向对象编程(OOP)所需的专业知识。此外,我们不会浪费时间将理论付诸实践,因为我们将在编写下一个项目,即 Pong 游戏时使用它。我们将深入了解如何通过编写类来创建我们可以用作对象的新类型。首先,我们将查看一个简化的 Pong 场景,以便了解一些类的基本知识,然后我们将重新开始,并使用我们学到的原则编写一个真正的 Pong 游戏。
在本章中,我们将涵盖以下主题:
-
通过一个假设的
Bat
类来了解面向对象编程和类 -
开始制作 Pong 游戏,并编写一个代表玩家球拍的真正类
面向对象编程
面向对象编程是一种编程范式,我们可以将其视为几乎标准的编程方式。确实存在非面向对象编程的方式,甚至还有一些非面向对象的游戏编程语言/库。然而,由于我们是从头开始的,没有理由以任何其他方式做事。
面向对象编程将做到以下几件事:
-
使我们的代码更容易管理、更改或更新
-
使我们的代码编写更快、更可靠
-
使其他人的代码(如我们使用 SFML)易于使用
我们已经看到了第三个好处。让我们讨论一下面向对象编程的确切含义。
面向对象编程是一种编程方式,它涉及将我们的需求分解成比整体更易于管理的块。每个块都是自包含的,并且可以被其他程序潜在地重用,同时与其他块作为一个整体一起工作。这些块就是我们所说的对象。
当我们计划和编写一个对象时,我们使用类来这样做。
小贴士
一个类可以被看作是对象的蓝图。
我们实现了一个类的对象。这被称为类的实例。想想房子的蓝图。你不能住在里面,但你可以用它来建造房子。你建造了一个房子的实例。通常,当我们为游戏设计类时,我们会编写它们来代表现实世界中的事物。在下一个项目中,我们将编写代表玩家控制的蝙蝠和玩家可以用球拍在屏幕上弹跳的球的类。然而,面向对象编程不仅仅是这些。
小贴士
面向对象编程(OOP)是一种做事的方式,一种定义最佳实践的方法论。
面向对象编程的三个核心原则是封装、多态和继承。这听起来可能很复杂,但一步一步来,这实际上是相当直接的。
封装
封装意味着保护你的代码的内部工作不被使用它的代码干扰。你可以通过只允许你选择的变量和函数被访问来实现这一点。这意味着你的代码总是可以更新、扩展或改进,而不会影响使用它的程序,前提是暴露的部分仍然以相同的方式被访问。
例如,如果 SFML 团队需要更新他们的Sprite
类的工作方式,只要函数签名保持不变,他们就不必担心内部发生了什么。我们在更新之前编写的代码在更新后仍然可以工作。
多态性
多态性允许我们编写不那么依赖于我们试图操作的类型的代码。这将使我们的代码更清晰、更高效。多态性意味着不同的形式。如果我们编写的对象可以是多种类型,那么我们可以利用这一点。在这个阶段,多态性可能听起来有点像黑魔法。我们将在第四个项目中使用多态性,该项目将在第十四章“抽象和代码管理——更好地利用面向对象编程”中开始,一切都将变得清晰。
继承
正如其名所示,继承意味着我们可以利用其他人的类的所有功能和好处,包括封装和多态,同时进一步改进它们的代码以适应我们的特定情况。我们将首次在同时使用多态性时使用继承。
为什么使用面向对象编程(OOP)?
当正确编写时,面向对象编程(OOP)允许你添加新功能而不必担心它们如何与现有功能交互。当你确实需要更改一个类时,它自包含(封装)的特性意味着对程序的其他部分的影响更小或甚至为零。
你可以使用别人的代码(如 SFML 类),而不必知道或者甚至关心它是如何工作的。
面向对象编程(OOP)以及 SFML,允许你编写使用复杂概念的游戏,如多个摄像头、多人游戏、OpenGL、方向性声音等——所有这些都不需要费太多力气。
通过使用继承,你可以创建多个类似但不同的类的版本,而无需从头开始创建类。
由于多态性,你仍然可以使用为原始类型的对象设计的函数来使用你的新对象。
所有这些确实很有道理。正如我们所知,C++从一开始就是为了实现所有这些面向对象编程(OOP)而设计的。
小贴士
在面向对象编程和制作游戏(或任何其他类型的应用程序)中取得成功的最终关键,除了成功的决心之外,是规划和设计。这不仅仅是“知道”所有 C++、SFML 和面向对象编程主题将帮助你编写优秀的代码,而是将所有这些知识应用于编写结构良好/设计良好的代码。本书中的代码以适合在游戏环境中学习各种 C++主题的顺序和方式呈现。结构代码的艺术和科学被称为设计模式。随着代码变得越来越长和复杂,有效使用设计模式将变得更加重要。好消息是,我们不需要自己发明这些设计模式。随着项目的复杂化,我们需要学习它们。随着项目的复杂化,我们的设计模式也会发展。
在这个项目中,我们将学习并使用基本的类和封装。随着本书的深入,我们将变得更加大胆,并使用继承、多态和其他与面向对象编程相关的 C++特性。
究竟什么是类?
类是一组代码,可以包含函数、变量、循环以及我们已经学习过的所有其他 C++语法。每个新的类都将声明在其自己的.h
代码文件中,文件名与类名相同,而其函数将在它们自己的.cpp
文件中定义。
一旦我们编写了一个类,我们就可以用它来创建尽可能多的对象。记住,类是蓝图,我们根据蓝图来创建对象。房子不是蓝图,就像对象不是类一样。它是由类制作的对象。
小贴士
你可以把一个对象想象成一个变量,把类想象成一个类型。
当然,在所有关于面向对象编程和类的讨论中,我们实际上还没有看到任何代码。现在让我们来解决这个问题。
Pong 蝙蝠的理论
接下来是对如何使用面向对象编程来通过编写蝙蝠类开始 Pong 项目的假设性讨论。现在请不要向项目中添加任何代码,因为以下内容过于简化,只是为了解释理论。在本章的后面部分,我们将真正编写它。当我们真正编写类时,它实际上会非常不同,但在这里我们将学习的原则将为我们成功做好准备。
我们将首先从类的一部分开始探索变量和函数。
类变量和函数声明
一个能够反弹球的蝙蝠是一个作为类的绝佳候选者。
小贴士
如果你不知道什么是 Pong,那么请查看这个链接:en.wikipedia.org/wiki/Pong
。
让我们看看一个假设的Bat.h
文件:
class Bat
{
private:
// Length of the pong bat
int m_Length = 100;
// Height of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
public:
void moveRight();
void moveLeft();
};
乍一看,代码可能看起来有点复杂,但当我们解释它时,我们会看到我们还没有覆盖的非常少的概念。
首先,要注意的是,使用 class
关键字声明一个新的类,后跟类名,整个声明被大括号包围,最后以分号结尾:
class Bat
{
…
…
};
现在,让我们来看看变量声明及其名称:
// Length of the pong bat
int m_Length = 100;
// Height of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
所有名称都以前缀 m_
开头。这个 m_
前缀不是强制性的,但这是一个好的约定。作为类一部分声明的变量被称为 m_
,这使得当我们处理成员变量时非常明确。当我们为我们的类编写函数时,我们还将开始看到局部(非成员)变量和参数。m_
约定将证明其有用性。
此外,请注意,所有变量都位于以 private:
关键字开头的代码部分中。扫描一下之前的代码,并注意类代码的主体被分为两个部分:
private:
// more code here
public:
// More code here
public
和 private
关键字控制了我们类的封装。任何私有内容都不能直接由类的实例/对象的用户访问。如果你正在为他人设计一个类,你不想让他们随意更改任何内容。请注意,成员变量不必是私有的,但通过尽可能使它们私有,我们可以实现良好的封装。
这意味着我们的四个成员变量(m_Length
、m_Height
、m_XPosition
和 m_YPosition
)不能直接从 main
函数中由我们的游戏引擎访问。它们只能通过类的代码间接访问。这就是封装的作用。对于 m_Length
和 m_Height
变量来说,只要我们不需要改变球拍的大小,这很容易接受。然而,对于 m_XPosition
和 m_YPosition
成员变量,它们需要被访问,否则我们如何移动球拍呢?
这个问题在代码的 public:
部分得到解决,如下所示:
void moveRight();
void moveLeft();
类提供了两个公共函数,可以用 Bat
类型的对象使用。当我们查看这些函数的定义时,我们将看到这些函数是如何精确地操作私有变量的。
总结来说,我们有一系列不可访问(私有)的变量,不能从 main
函数中使用。这是好的,因为封装使我们的代码更少出错,更易于维护。然后我们通过提供两个公共函数来间接访问 m_XPosition
和 m_YPosition
变量,从而解决了移动球拍的问题。
main
函数中的代码可以使用类的实例调用这些函数,但函数内部的代码控制着变量的具体使用方式。
让我们来看看函数的定义。
类函数定义
我们将在本书中编写的函数定义都将放在与类和函数声明分开的文件中。我们将使用与类同名的文件,并具有.cpp
文件扩展名。例如,以下代码将放在名为Bat.cpp
的文件中。看看以下代码,它只有一个新概念:
#include "Bat.h"
void Bat::moveRight()
{
// Move the bat a pixel to the right
xPosition ++;
}
void Bat::moveLeft()
{
// Move the bat a pixel to the left
xPosition --;
}
首先要注意的是,我们必须使用包含指令来包含Bat.h
文件中的类和函数声明。
我们在这里可以看到的新概念是使用::
。由于函数属于一个类,我们必须通过在函数名前加上类名以及::
来编写签名部分。例如,void Bat::moveLeft()
和void Bat::moveRight
。
重要提示
实际上,我们之前已经简要地见过作用域解析运算符,即每次我们声明一个类的对象,并且我们没有之前使用using namespace..
。
注意,我们可以将函数定义和声明放在一个文件中,如下所示:
class Bat
{
private:
// Length of the pong bat
int m_Length = 100;
// Length of the pong bat
int m_Height = 10;
// Location on x axis
int m_XPosition;
// Location on y axis
int m_YPosition;
public:
void Bat::moveRight()
{
// Move the bat a pixel to the right
xPosition ++;
}
void Bat::moveLeft()
{
// Move the bat a pixel to the left
xPosition --;
}
};
然而,当我们的类变得更长(就像我们的第一个 Zombie Arena 类那样),将函数定义分离到它们自己的文件中会更加有序。此外,头文件被认为是“公共的”,如果其他人将使用我们编写的代码,它们通常用于文档目的。
但一旦我们编写了类,我们该如何使用它呢?
使用类的实例
尽管我们已经看到了与类相关的所有代码,但我们实际上并没有使用类。我们已经知道如何做到这一点,因为我们已经多次使用了 SFML 类。
首先,我们会创建一个Bat
类的实例,如下所示:
Bat bat;
bat
对象拥有我们在Bat.h
中声明的所有变量。我们无法直接访问它们。然而,我们可以通过其公共函数移动我们的蝙蝠,如下所示:
bat.moveLeft();
或者我们可以这样移动它:
bat.moveRight();
记住bat
是一个Bat
,因此它具有所有成员变量,并且可以使用所有可用的函数。
之后,我们可能会决定将我们的 Pong 游戏改为多人游戏。在main
函数中,我们可以更改代码,使游戏有两个蝙蝠,可能如下所示:
Bat bat;
Bat bat2;
重要的是要认识到,这些Bat
实例中的每一个都是独立的对象,拥有它们自己的变量集。初始化一个类实例的方法有很多种,当我们真正为Bat
类编写代码时,我们会看到这个例子。
现在,我们可以真正开始项目了。
创建 Pong 项目
由于设置项目是一个繁琐的过程,我们将一步一步地进行,就像我们在 Timber!!!项目中做的那样。我不会展示与 Timber!!!项目相同的截图,但过程是相同的,所以如果你想提醒各种项目属性的地点,请翻回第一章,C++,SFML,Visual Studio 和开始第一个游戏:
-
启动 Visual Studio 并点击创建新项目按钮。或者,如果你仍然打开了 Timber!!!项目,你可以选择文件 | 新项目。
-
在随后显示的窗口中,选择控制台应用程序并点击下一步按钮。然后你会看到配置你的新项目窗口。
-
在项目名称字段中的
Pong
。请注意,这会导致 Visual Studio 自动配置解决方案名称字段,使其具有相同的名称。 -
在我们在第一章中创建的
VS Projects
文件夹中。就像 Timber!!!项目一样,这将是所有项目文件存放的位置。 -
选择将解决方案和项目放在同一目录下的选项。
-
完成这些步骤后,点击
main.cpp
文件,就像之前做的那样。 -
现在,我们将配置项目以使用我们放在
SFML
文件夹中的 SFML 文件。从主菜单中选择项目 | Pong 属性…。在这个阶段,你应该已经打开了Pong 属性页窗口。 -
在Pong 属性页窗口中,从配置:下拉菜单中选择所有配置。
-
现在,从左侧菜单中选择C/C++然后选择常规。
-
然后,定位到
\SFML\include
。如果你将SFML
文件夹放在 D 驱动器上,需要输入的完整路径是D:\SFML\include
。如果你在另一个驱动器上安装了 SFML,请更改路径。 -
再次点击应用以保存到目前为止的配置。
-
现在,仍然在这个窗口中,执行以下步骤。从左侧菜单中选择链接器然后选择常规。
-
现在,找到
SFML
文件夹的位置,然后是\SFML\lib
。所以,如果你将SFML
文件夹放在 D 驱动器上,需要输入的完整路径是D:\SFML\lib
。如果你在另一个驱动器上安装了 SFML,请更改路径。 -
点击应用以保存到目前为止的配置。
-
接下来,仍然在这个窗口中,执行以下步骤。将配置:下拉菜单切换到调试,因为我们将在调试模式下运行和测试 Pong。
-
选择链接器然后选择输入。
-
找到
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。请格外小心地将光标放在编辑框当前内容的起始位置,以免覆盖任何已经存在的文本。 -
点击确定。
-
点击应用然后确定。
-
现在,我们需要将 SFML
.dll
文件复制到主项目目录中。我的主项目目录是D:\VS Projects\Pong
。这是在之前的步骤中由 Visual Studio 创建的。如果你将VS Projects
文件夹放在了其他位置,那么请在此处执行此步骤。我们需要复制到项目文件夹中的文件位于我们的SFML\bin
文件夹中。为这两个位置打开一个窗口,并突出显示SFML\bin
文件夹中的所有文件。 -
现在,将突出显示的文件复制并粘贴到项目文件夹中,即
D:\VS Projects\Pong
。
我们现在已经配置好了项目属性,并准备就绪。
在这个游戏中,我们将显示一些文本用于 HUD(抬头显示),以显示玩家的得分和剩余生命。为此,我们需要一个字体。
重要提示
从 www.dafont.com/theme.php?cat=302
下载这个免费个人使用的字体并解压下载。或者你也可以自由选择你喜欢的字体。在我们加载字体时,你只需要对代码做一些小的修改。
在 VS Projects\Pong
文件夹中创建一个名为 fonts
的新文件夹,并将 DS-DIGIT.ttf
文件添加到 VS Projects\Pong\fonts
文件夹中。
现在我们准备好编写我们的第一个 C++ 类。
编写 Bat 类
简单的 Pong 球棒示例是介绍类的基本原理的好方法。类可以很简单且简短,就像前面的 Bat
类一样,但它们也可以更长更复杂,并包含由其他类创建的其他对象。
当涉及到制作游戏时,假设的 Bat
类缺少一些至关重要的东西。对于所有这些私有成员变量和公共函数来说可能没问题,但我们如何绘制任何东西呢?我们的 Pong 球棒需要一个精灵,在某些游戏中,它们还需要一个纹理。此外,我们需要一种方法来控制所有游戏对象的动画速率,就像我们在上一个项目中处理蜜蜂和云朵时做的那样。我们可以以与我们在 main.cpp
文件中包含它们完全相同的方式在我们的类中包含其他对象。让我们真正编写我们的 Bat
类,以便我们可以看到如何解决所有这些问题。
Coding Bat.h
要开始,我们将编写头文件。右键点击 Bat.h
。点击 添加 按钮。我们现在准备好编写文件了。
将以下代码添加到 Bat.h
中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bat
{
private:
Vector2f m_Position;
// A RectangleShape object
RectangleShape m_Shape;
float m_Speed = 1000.0f;
bool m_MovingRight = false;
bool m_MovingLeft = false;
public:
Bat(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
void moveLeft();
void moveRight();
void stopLeft();
void stopRight();
void update(Time dt);
};
首先,注意文件顶部的 #pragma once
声明。这可以防止文件被编译器多次处理。随着我们的游戏变得越来越复杂,可能有几十个类,这将加快编译时间。
注意成员变量的名称和函数的参数以及返回类型。我们有一个名为 m_Position
的 Vector2f
,它将保存玩家球棒的水平和垂直位置。我们还有一个 SFML 的 RectangleShape
,它将是实际出现在屏幕上的球棒。
有两个布尔成员变量将跟踪球棒当前正在移动的方向(如果有的话),我们还有一个名为 m_Speed
的 float
,它告诉我们当玩家决定将球棒向左或向右移动时,球棒每秒可以移动多少像素。
代码的下一部分需要一些解释,因为我们有一个名为 Bat
的函数;这个名字与类的名字完全相同。这被称为构造函数。
构造函数
当一个类被编写时,编译器会创建一个特殊函数。我们在代码中看不到这个函数,但它确实存在。它被称为构造函数。如果使用了假设的 Bat
类示例,这将是一个会被调用的函数。
当我们需要编写一些代码来准备一个对象以供使用时,通常一个好的地方是在构造函数中做这件事。当我们想让构造函数执行除了简单地创建实例之外的其他操作时,我们必须替换编译器提供的默认(未看到的)构造函数。这就是我们将要做的Bat
构造函数。
注意,Bat
构造函数接受两个float
参数。这非常适合在第一次创建Bat
对象时初始化屏幕上的位置。另外,请注意构造函数没有返回类型,甚至不是void
。
我们很快将使用构造函数Bat
将这个游戏对象放置到起始位置。记住,这个函数是在声明Bat
类型的对象时调用的。
继续解释 Bat.h
接下来是getPosition
函数,它返回一个FloatRect
,定义矩形的四个点。然后,我们有getShape
,它返回一个RectangleShape
。这将用于返回到主游戏循环m_Shape
,以便它可以被绘制。
我们还有moveLeft
、moveRight
、stopLeft
和stopRight
函数,这些函数用于控制蝙蝠何时以及朝哪个方向移动。
最后,我们有update
函数,它接受一个Time
参数。这个函数将用于计算每一帧如何移动蝙蝠。由于蝙蝠和球将彼此移动得相当不同,因此将移动代码封装在类中是有意义的。我们将从main
函数中每帧调用一次update
函数。
小贴士
你可能能猜到Ball
类也将有一个update
函数。
现在,我们可以编写Bat.cpp
,它将实现所有定义并使用成员变量。
编写 Bat.cpp
让我们创建文件,然后我们可以开始讨论代码。在名称字段中右键点击Bat.cpp
。点击添加按钮,我们的新文件就会为我们创建。
我们将把这个文件的代码分成两部分,以便更容易讨论。
首先,编写Bat
构造函数,如下所示:
#include "Bat.h"
// This the constructor and it is called when we create an object
Bat::Bat(float startX, float startY)
{
m_Position.x = startX;
m_Position.y = startY;
m_Shape.setSize(sf::Vector2f(50, 5));
m_Shape.setPosition(m_Position);
}
在前面的代码中,我们可以看到我们包含了bat.h
文件。这使得之前在bat.h
中声明的所有函数和变量都对我们可用。
我们实现构造函数是因为我们需要做一些工作来设置实例,而编译器提供的默认未看到的空构造函数是不够的。记住,构造函数是在我们初始化Bat
实例时运行的代码。
注意,我们使用Bat::Bat
语法作为函数名,以使其清楚我们正在使用Bat
类中的Bat
函数。
此构造函数接收两个 float
值,startX
和 startY
。接下来发生的事情是我们将这些值分配给 m_Position.x
和 m_Position.y
。名为 m_Position
的 Vector2f
现在持有传递的值,因为 m_Position
是成员变量,所以这些值在整个类中都是可访问的。然而,请注意,m_Position
被声明为 private
,因此在我们的 main
函数文件中不可访问——至少不是直接访问。我们很快就会看到如何解决这个问题。
最后,在构造函数中,我们通过设置其大小和位置来初始化名为 m_Shape
的 RectangleShape
。这与我们在 《乒乓球拍理论》 部分中编写的假设 Bat
类的方式不同。SFML 的 Sprite
类提供了方便的大小和位置变量,我们可以使用 setSize
和 setPosition
函数来访问它们,因此我们不再需要假设的 m_Length
和 m_Height
。
此外,请注意,我们需要调整初始化 Bat
类的方式(与假设的 Bat
类相比),以适应我们的自定义构造函数。
我们需要实现 Bat
类剩余的五个函数。在我们刚刚讨论的构造函数之后,将以下代码添加到 Bat.cpp
中:
FloatRect Bat::getPosition()
{
return m_Shape.getGlobalBounds();
}
RectangleShape Bat::getShape()
{
return m_Shape;
}
void Bat::moveLeft()
{
m_MovingLeft = true;
}
void Bat::moveRight()
{
m_MovingRight = true;
}
void Bat::stopLeft()
{
m_MovingLeft = false;
}
void Bat::stopRight()
{
m_MovingRight = false;
}
void Bat::update(Time dt)
{
if (m_MovingLeft) {
m_Position.x -= m_Speed * dt.asSeconds();
}
if (m_MovingRight) {
m_Position.x += m_Speed * dt.asSeconds();
}
m_Shape.setPosition(m_Position);
}
让我们来看看我们刚刚添加的代码。
首先,我们有 getPosition
函数。它所做的只是返回一个 FloatRect
给调用它的代码。代码行 m_Shape.getGlobalBounds
返回一个 FloatRect
,该 FloatRect
使用 RectangleShape
(即 m_Shape
)四个角坐标进行初始化。当我们确定球是否击中球拍时,我们将从这个函数中调用 main
函数。
接下来,我们有 getShape
函数。这个函数所做的只是将 m_Shape
的副本传递给调用代码。这是必要的,这样我们就可以在 main
函数中绘制球拍。当我们编写一个仅用于从类中返回私有数据的公共函数时,我们称之为 getter 函数。
现在,我们可以看看 moveLeft
、moveRight
、stopLeft
和 stopRight
函数。它们所做的只是适当地设置 m_MovingLeft
和 m_MovingRight
布尔变量,以便跟踪玩家的当前意图。然而,请注意,它们对确定位置的 RectangleShape
实例或 FloatRect
实例没有任何操作。这正是我们所需要的。
Bat
类中的最后一个函数是update
。我们将每帧调用这个函数一次。随着我们的游戏项目变得更加复杂,update
函数的复杂度将增加。目前,我们只需要根据玩家是否向左或向右移动来调整m_Position
。请注意,用于此调整的公式与我们用于 Timber!!!项目中更新蜜蜂和云的公式相同。代码将速度乘以 delta 时间,然后从位置中添加或减去它,这使得蝙蝠相对于帧更新所需的时间移动。接下来,代码使用m_Position
中保存的最新值设置m_Shape
的位置。
在我们的Bat
类中有一个update
函数,而不是在main
函数中,这是一种封装。我们不像在 Timber!!!项目中那样在main
函数中更新所有游戏对象的位置,每个对象将负责更新自己。然而,接下来我们将从main
函数中调用这个update
函数。
使用Bat
类和编写主函数
切换到当我们创建项目时自动生成的main.cpp
文件。删除所有自动生成的代码,并添加以下代码。
按照以下方式编写Pong.cpp
文件:
#include "Bat.h"
#include <sstream>
#include <cstdlib>
#include <SFML/Graphics.hpp>
int main()
{
// Create a video mode object
VideoMode vm(1920, 1080);
// Create and open a window for the game
RenderWindow window(vm, "Pong", Style::Fullscreen);
int score = 0;
int lives = 3;
// Create a bat at the bottom center of the screen
Bat bat(1920 / 2, 1080 - 20);
// We will add a ball in the next chapter
// Create a Text object called HUD
Text hud;
// A cool retro-style font
Font font;
font.loadFromFile("fonts/DS-DIGI.ttf");
// Set the font to our retro-style
hud.setFont(font);
// Make it nice and big
hud.setCharacterSize(75);
// Choose a color
hud.setFillColor(Color::White);
hud.setPosition(20, 20);
// Here is our clock for timing everything
Clock clock;
while (window.isOpen())
{
/*
Handle the player input
****************************
****************************
****************************
*/
/*
Update the bat, the ball and the HUD
*****************************
*****************************
*****************************
*/
/*
Draw the bat, the ball and the HUD
*****************************
*****************************
*****************************
*/
}
return 0;
}
在前面的代码中,结构与我们用于 Timber!!!项目的结构相似。然而,第一个例外是我们创建Bat
类的实例时:
// Create a bat
Bat bat(1920 / 2, 1080 - 20);
前面的代码调用构造函数来创建Bat
类的新实例。代码传递所需的参数并允许Bat
类将其位置初始化在屏幕中央靠近底部。这是我们球拍开始的最佳位置。
还要注意,我已经使用注释来指示其余代码最终将被放置的位置。所有这些都在游戏循环中,就像在 Timber!!!项目中一样。以下是将放置其余代码的位置,仅供参考:
/*
Handle the player input
…
/*
Update the bat, the ball and the HUD
…
/*
Draw the bat, the ball and the HUD
…
接下来,将以下代码添加到处理玩家输入
部分:
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::Closed)
// Quit the game when the window is closed
window.close();
}
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Handle the pressing and releasing of the arrow keys
if (Keyboard::isKeyPressed(Keyboard::Left))
{
bat.moveLeft();
}
else
{
bat.stopLeft();
}
if (Keyboard::isKeyPressed(Keyboard::Right))
{
bat.moveRight();
}
else
{
bat.stopRight();
}
前面的代码通过按下Escape键处理玩家退出游戏的情况,这与 Timber!!!项目中的做法完全一样。接下来,有两个if
– else
结构来处理玩家移动球拍。让我们分析这两个结构中的第一个:
if (Keyboard::isKeyPressed(Keyboard::Left))
{
bat.moveLeft();
}
else
{
bat.stopLeft();
}
前面的代码将检测玩家是否按下了键盘上的左箭头光标键。如果是,则对Bat
实例调用moveLeft
函数。当这个函数被调用时,true
值被设置为m_MovingLeft
私有布尔变量。如果左箭头键没有被按下,则调用stopLeft
函数并将m_MovingLeft
设置为false
。
然后在下一个if
– else
代码块中重复上述过程来处理玩家按下(或未按下)右箭头键。
接下来,将以下代码添加到“更新蝙蝠、球和 HUD”部分,如下所示:
// Update the delta time
Time dt = clock.restart();
bat.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
在前面的代码中,我们使用了与 Timber!!!项目相同的精确计时技术,但这次我们在Bat
实例上调用update
并传入 delta 时间。记住,当Bat
类接收到 delta 时间时,它将使用该值根据之前从玩家接收到的移动指令和蝙蝠的期望速度来移动蝙蝠。
接下来,将以下代码添加到“绘制蝙蝠、球和 HUD”部分,如下所示:
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.display();
在前面的代码中,我们清空了屏幕,绘制了 HUD 的文本,并使用bat.getShape
函数从Bat
实例中获取RectangleShape
实例并将其绘制到屏幕上。最后,我们调用window.display
,就像我们在上一个项目中做的那样,以在当前位置绘制蝙蝠。
在这个阶段,你可以运行游戏,你会看到 HUD 和一只蝙蝠。你可以使用箭头/光标键平滑地左右移动蝙蝠:
恭喜!这是第一个类,已经全部编写并部署。
摘要
在本章中,我们发现了 OOP 的基础知识,例如如何编码和使用类,包括利用封装来控制外部代码如何访问我们的成员变量,但仅限于我们希望的方式和程度。这就像 SFML 类一样,允许我们创建和使用Sprite
和Text
实例,但只能按照它们设计的方式使用。
如果关于 OOP 和类的一些细节不是很清楚,请不要过于担心。我之所以这么说,是因为我们将在这本书的剩余部分编写类,并且我们使用得越多,它们就会变得越清晰。
此外,我们有一个正在工作的蝙蝠和我们的 Pong 游戏的 HUD。
在下一章中,我们将编写Ball
类并使其在屏幕上弹跳。然后我们可以添加碰撞检测并完成游戏。
常见问题解答
Q) 我已经学习了其他语言,并且感觉 C++中的 OOP 要简单得多。这种评估是正确的吗?
A) 这是对 OOP 及其基本原理的介绍。这只是一个开始。在这本书中,我们将学习更多关于 OOP 的概念和细节。
第八章:第七章:动态碰撞检测和物理——完成 Pong 游戏
在本章中,我们将编写我们的第二个类。我们将看到,尽管球显然与棒子有很大的不同,但我们将使用完全相同的技巧在Ball
类中封装球的形状和功能,就像我们对棒子和Bat
类所做的那样。然后,我们将通过编写一些动态碰撞检测和计分功能来完善 Pong 游戏。这听起来可能很复杂,但正如我们所期待的,SFML 将使事情比其他方式更容易。
本章我们将涵盖以下主题:
-
编写
Ball
类 -
使用
Ball
类 -
碰撞检测和计分
-
运行游戏
我们将首先编写代表球的类。
编写Ball
类
要开始,我们将编写头文件。在Ball.h
上右键单击。点击添加按钮。现在,我们准备好编写文件。
将以下代码添加到Ball.h
中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Ball
{
private:
Vector2f m_Position;
RectangleShape m_Shape;
float m_Speed = 300.0f;
float m_DirectionX = .2f;
float m_DirectionY = .2f;
public:
Ball(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
float getXVelocity();
void reboundSides();
void reboundBatOrTop();
void reboundBottom();
void update(Time dt);
};
你首先会注意到与Bat
类相比成员变量的相似性。有一个用于位置的成员变量,外观和速度,就像玩家的棒子一样,它们的类型相同(Vector2f
、RectangleShape
和float
分别)。它们甚至有相同的名称(m_Position
、m_Shape
和m_Speed
分别)。这个类成员变量之间的区别在于,方向由两个float
变量处理,这些变量将跟踪水平和垂直移动。这些是m_DirectionX
和m_DirectionY
。
注意,我们需要编写八个函数来使球变得生动。有一个与类名相同的构造函数,我们将使用它来初始化一个Ball
实例。有三个与Bat
类相同名称和用途的函数。它们是getPosition
、getShape
和update
。getPosition
和getShape
函数将与main
函数共享球的位置和外观,而update
函数将从main
函数中调用,以允许Ball
类在每一帧更新其位置。
剩余的函数控制球将移动的方向。当检测到与屏幕任一侧的碰撞时,reboundSides
函数将从main
中调用,当球击中玩家的棒子或屏幕顶部时,将调用reboundBatOrTop
函数,当球击中屏幕底部时,将调用reboundBottom
函数。
当然,这些只是声明,所以让我们在Ball.cpp
文件中编写实际工作的 C++代码。
让我们创建文件,然后我们可以开始讨论代码。在名称字段中右键单击Ball.cpp
。点击添加按钮,我们的新文件将为我们创建。
将以下代码添加到Ball.cpp
中:
#include "Ball.h"
// This the constructor function
Ball::Ball(float startX, float startY)
{
m_Position.x = startX;
m_Position.y = startY;
m_Shape.setSize(sf::Vector2f(10, 10));
m_Shape.setPosition(m_Position);
}
在前面的代码中,我们已添加了Ball
类头文件的必需include
指令。与类名相同的构造函数接收两个float
参数,用于初始化m_Position
成员的Vector2f
实例。然后使用setSize
函数设置RectangleShape
实例的大小,并使用setPosition
函数定位。使用的大小是 10 像素宽和 10 像素高;这是任意的,但效果很好。使用的位置当然是从m_Position Vector2f
实例中获取的。
在Ball.cpp
函数的构造函数下方添加以下代码:
FloatRect Ball::getPosition()
{
return m_Shape.getGlobalBounds();
}
RectangleShape Ball::getShape()
{
return m_Shape;
}
float Ball::getXVelocity()
{
return m_DirectionX;
}
在前面的代码中,我们正在编写Ball
类的三个 getter 函数。它们各自向main
函数返回一些内容。第一个是getPosition
,它使用m_Shape
上的getGlobalBounds
函数返回一个FloatRect
实例。这将用于碰撞检测。
getShape
函数返回m_Shape
,以便在游戏循环的每一帧中绘制。getXVelocity
函数告诉main
函数球体正在移动的方向,我们很快就会看到这如何对我们有用。由于我们永远不需要获取垂直速度,因此没有相应的getYVelocity
函数,但如果我们需要,添加一个也很简单。
在我们刚刚添加的代码下方添加以下函数:
void Ball::reboundSides()
{
m_DirectionX = -m_DirectionX;
}
void Ball::reboundBatOrTop()
{
m_DirectionY = -m_DirectionY;
}
void Ball::reboundBottom()
{
m_Position.y = 0;
m_Position.x = 500;
m_DirectionY = -m_DirectionY;
}
在前面的代码中,以rebound...
开头的三个函数处理球体与各种位置的碰撞情况。在reboundSides
函数中,m_DirectionX
的值被反转,这将使正值变为负值,负值变为正值,从而反转(水平)球体移动的方向。reboundBatOrTop
函数与reboundSides
函数完全相同,但作用于m_DirectionY
,这将反转球体垂直移动的方向。reboundBottom
函数将球体重新定位到屏幕顶部中央,并将其向下发送。这就是玩家错过球体并击中屏幕底部后我们想要的。
最后,为Ball
类添加更新函数,如下所示:
void Ball::update(Time dt)
{
// Update the ball's position
m_Position.y += m_DirectionY * m_Speed * dt.asSeconds();
m_Position.x += m_DirectionX * m_Speed * dt.asSeconds();
// Move the ball
m_Shape.setPosition(m_Position);
}
在前面的代码中,m_Position.y
和m_Position.x
使用适当的方向速度、速度和当前帧完成所需的时间进行更新。然后使用新更新的m_Position
值来更改m_Shape RectangleShape
实例的位置。
Ball
类已完成,现在让我们将其投入使用。
使用Ball
类
要使Ball
类在main
函数中可用,请将以下代码添加到使球体生效的部分:
#include "Ball.h"
添加以下高亮显示的代码行,使用我们刚刚编写的构造函数声明并初始化Ball
类的一个实例:
// Create a bat
Bat bat(1920 / 2, 1080 - 20);
// Create a ball
Ball ball(1920 / 2, 0);
// Create a Text object called HUD
Text hud;
将以下代码添加到高亮显示的位置:
/*
Update the bat, the ball and the HUD
****************************************************
****************************************************
****************************************************
*/
// Update the delta time
Time dt = clock.restart();
bat.update(dt);
ball.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
在前面的代码中,我们只是对ball
实例调用update
。球将相应地重新定位。
将以下高亮代码添加到游戏循环的每一帧上绘制球:
/*
Draw the bat, the ball and the HUD
*********************************************
*********************************************
*********************************************
*/
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.draw(ball.getShape());
window.display();
在这个阶段,你可以运行游戏,球会在屏幕顶部生成并开始向屏幕底部下降。然而,球会从屏幕底部消失,因为我们还没有检测到任何碰撞。现在让我们解决这个问题。
碰撞检测和得分
与 Timber!!!游戏不同,我们当时只是检查最低位置的树枝是否与玩家的角色在同一个侧面,在这个游戏中,我们需要通过数学方法检查球与球拍或球与屏幕四边的交点。
让我们看看一些假设的代码,以便我们理解我们在做什么。然后,我们将转向 SFML 来为我们解决这个问题。
测试两个矩形相交的代码可能看起来像这样。不要使用以下代码。它仅用于演示目的:
if(objectA.getPosition().right > objectB.getPosition().left
&& objectA.getPosition().left < objectB.getPosition().right )
{
// objectA is intersecting objectB on x axis
// But they could be at different heights
if(objectA.getPosition().top < objectB.getPosition().bottom
&& objectA.getPosition().bottom > objectB.getPosition().top )
{
// objectA is intersecting objectB on y axis as well
// Collision detected
}
}
我们不需要编写此代码;然而,我们将使用 SFML 的intersects
函数,该函数作用于FloatRect
对象。回想或查看Bat
和Ball
类;它们都有getPosition
函数,该函数返回对象的当前位置的FloatRect
。我们将看到如何使用getPosition
和intersects
来完成所有的碰撞检测。
在主函数的更新部分末尾添加以下高亮代码:
/*
Update the bat, the ball and the HUD
**************************************
**************************************
**************************************
*/
// Update the delta time
Time dt = clock.restart();
bat.update(dt);
ball.update(dt);
// Update the HUD text
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
// Handle ball hitting the bottom
if (ball.getPosition().top > window.getSize().y)
{
// reverse the ball direction
ball.reboundBottom();
// Remove a life
lives--;
// Check for zero lives
if (lives < 1) {
// reset the score
score = 0;
// reset the lives
lives = 3;
}
}
在前面的代码中,第一个if
条件检查球是否触碰到屏幕底部:
if (ball.getPosition().top > window.getSize().y)
如果球的上部位置高于窗口的高度,那么球已经从玩家的视图中消失。作为回应,调用ball.reboundBottom
函数。记住,在这个函数中,球被重新定位到屏幕顶部。此时,玩家失去了一条生命,因此lives
变量被递减。
第二个if
条件检查玩家是否用完了生命(lives < 1
)。如果是这种情况,分数重置为 0,生命值重置为 3,游戏重新开始。在下一个项目中,我们将学习如何保存和显示玩家的最高分。
在前面的代码下方添加以下代码:
// Handle ball hitting top
if (ball.getPosition().top < 0)
{
ball.reboundBatOrTop();
// Add a point to the players score
score++;
}
在前面的代码中,我们检测到球的上部触碰到屏幕的上部。当这种情况发生时,玩家获得一分,并调用ball.reboundBatOrTop
,这将反转球的垂直运动方向,并将球送回屏幕底部。
在前面的代码下方添加以下代码:
// Handle ball hitting sides
if (ball.getPosition().left < 0 ||
ball.getPosition().left + ball.getPosition().width> window.getSize().x)
{
ball.reboundSides();
}
在前面的代码中,if
条件检测到球与屏幕左侧的碰撞或球右侧(左+10)与屏幕右侧的碰撞。在任何一种情况下,都会调用ball.reboundSides
函数,并反转水平移动的方向。
添加以下代码:
// Has the ball hit the bat?
if (ball.getPosition().intersects(bat.getPosition()))
{
// Hit detected so reverse the ball and score a point
ball.reboundBatOrTop();
}
在前面的代码中,intersects
函数用于确定球是否击中了球拍。当发生这种情况时,我们使用与屏幕顶部碰撞相同的函数来反转球垂直移动的方向。
运行游戏
你现在可以运行游戏,让球在屏幕上弹跳。当你用球拍击中球时,分数会增加,当你错过它时,生命值会减少。当lives
达到 0 时,分数将重置,lives
将回到 3,如下所示:
摘要
恭喜你;这是第二个完成的游戏!我们本可以给那个游戏添加更多功能,比如合作游戏、高分、音效等等,但我只是想用最简单的例子来介绍类和动态碰撞检测。现在我们有了这些主题在我们的游戏开发者工具箱中,我们可以继续到一个更加激动人心的项目,以及更多游戏开发主题。
在下一章中,我们将规划僵尸竞技场游戏,了解 SFML 的View
类,它作为进入我们游戏世界的虚拟摄像头,并编写一些更多的类。
常见问题解答
Q) 这款游戏是不是有点安静?
A) 我没有给这个游戏添加音效,因为我想要保持代码尽可能短,同时使用我们的第一个类,并学习如何使用时间来平滑地动画化所有游戏对象。如果你想添加音效,你只需要将.wav 文件添加到项目中,使用 SFML 加载声音,并在每个碰撞事件中播放一个音效。我们将在下一个项目中这样做。
Q) 游戏太简单了!我怎样才能让球的速度稍微快一点?
A) 有很多方法可以使游戏更具挑战性。一种简单的方法是在Ball
类的reboundBatOrTop
函数中添加一行代码来增加速度。例如,以下代码会在每次函数被调用时将球的速度增加 10%:
// Speed up a little bit on each hit
m_Speed = m_Speed * 1.1f;
球会很快变得非常快。当玩家失去所有生命时,你需要想出一个方法将速度重置回300.0f
。你可以在Ball
类中创建一个新的函数,可能叫做resetSpeed
,并在代码检测到玩家失去最后一条生命时从main
中调用它。
第九章:第八章:SFML 视图 – 启动僵尸射击器游戏
在这个项目中,我们将更多地使用View
类。这个多才多艺的类将使我们能够轻松地将游戏划分为不同方面的层。在僵尸射击器项目中,我们将有一个用于 HUD 的层和一个用于主游戏的层。这是必要的,因为随着玩家每次清除一波僵尸时游戏世界都会扩大,最终游戏世界将比屏幕大,需要滚动。使用View
类将防止 HUD 的文本与背景一起滚动。在下一个项目中,我们将更进一步,使用 SFML 的View
类创建一个合作分屏游戏,View
类将完成大部分繁重的工作。
这就是我们将在本章中要做的事情:
-
规划并启动僵尸竞技场游戏
-
编码
Player
类 -
了解 SFML 的
View
类 -
构建僵尸竞技场游戏引擎
-
使用
Player
类
规划并启动僵尸竞技场游戏
到目前为止,如果你还没有看过,我建议你去看一下Over 9000 Zombies (store.steampowered.com/app/273500/
) 和 Crimson Land (store.steampowered.com/app/262830/
) 的视频。显然,我们的游戏不会像这两个例子那样深入或高级,但我们将拥有相同的基本功能集和游戏机制,如下所示:
-
一个显示详细信息如分数、最高分、弹夹中的子弹数量、剩余子弹数量、玩家生命值和剩余待杀僵尸数量的抬头显示(HUD)。
-
玩家将在疯狂地逃离僵尸的同时射击它们。
-
使用WASD键盘键在移动的同时,用鼠标瞄准枪支。
-
在每个关卡之间,玩家将选择一个“升级”,这将影响玩家为了获胜而需要玩游戏的方式。
-
玩家需要收集“拾取物”来恢复生命值和弹药。
-
每一波都会带来更多的僵尸和更大的竞技场,使其更具挑战性。
将有三种类型的僵尸可供击杀。它们将具有不同的属性,如外观、生命值和速度。我们将它们称为追逐者、膨胀者和爬行者。查看以下带有注释的游戏截图,以了解一些功能在实际操作中的表现以及构成游戏的组件和资产:
下面是关于每个编号点的更多信息:
-
分数和最高分。这些,连同 HUD 的其他部分,将绘制在一个单独的层上,称为视图,并由
View
类的实例表示。最高分将被保存并加载到文件中。 -
一个纹理,将在竞技场周围建造墙壁。这个纹理包含在一个名为精灵图集的单个图形中,以及其他背景纹理(编号3、5和6)。
-
来自精灵图的第一个泥地纹理。
-
这是一个“弹药拾取”。当玩家获得这个时,他们将会获得更多的弹药。还有一个“健康拾取”,玩家将从中获得更多的生命。这些拾取可以在僵尸波之间由玩家选择升级。
-
来自精灵图的草地纹理。
-
来自精灵图的第二个泥地纹理。
-
僵尸曾经所在的地方的血溅。
-
HUD 的底部部分。从左到右,有一个代表弹药、弹夹中的子弹数量、备用子弹数量、生命条、当前僵尸波和当前波剩余僵尸数量的图标。
-
玩家的角色。
-
准星,玩家用鼠标瞄准。
-
一个缓慢移动但强大的“浮肿僵尸”。
-
一个稍微快一点的移动但较弱的“爬行僵尸”。还有一个非常快且弱的“追逐僵尸”。不幸的是,在他们都被杀死之前,我无法在截图中获得一个。
因此,我们有很多事情要做,还有很多新的 C++技能要学习。让我们从创建一个新项目开始。
创建新项目
由于创建项目是一个相对复杂的过程,我将再次详细说明所有步骤。对于更多细节和图片,请参阅第一章,C++、SFML、Visual Studio 和开始第一个游戏中的设置 Timber 项目部分。
由于设置项目是一个繁琐的过程,我们将一步一步地进行,就像我们在 Timber 项目中做的那样。我不会展示与 Timber 项目相同的图片,但过程是相同的,所以如果你想提醒各种项目属性的位置,请翻回第一章,C++、SFML、Visual Studio 和开始第一个游戏。让我们看看以下步骤:
-
启动 Visual Studio 并点击创建新项目按钮。如果你有其他项目打开,你可以选择文件 | 新建项目。
-
在下一个显示的窗口中,选择控制台应用程序并点击下一步按钮。然后你会看到配置你的新项目窗口。
-
在项目 名称字段中的
Zombie Arena
。 -
在
VS Projects
文件夹中。 -
选择将解决方案和项目放在同一目录下的选项。
-
当你完成前面的步骤后,点击创建。
-
现在,我们将配置项目以使用我们放在
SFML
文件夹中的 SFML 文件。从主菜单中选择项目 | 僵尸竞技场属性…。在这个阶段,你应该已经打开了僵尸竞技场属性页窗口。 -
在僵尸竞技场属性页窗口中,执行以下步骤。从配置:下拉菜单中选择所有配置。
-
现在,从左侧菜单中选择C/C++然后选择常规。
-
接下来,定位到
\SFML\include
。如果你将SFML
文件夹位于你的 D 驱动器上,要输入的完整路径将是D:\SFML\include
。如果你将 SFML 安装在不同的驱动器上,请更改你的路径。 -
点击 应用 以保存到目前为止的配置。
-
现在,仍然在同一窗口中,执行以下下一步。从左侧菜单中选择 链接器 然后选择 常规。
-
现在,找到
SFML
文件夹,然后是\SFML\lib
。所以,如果你将SFML
文件夹位于你的 D 驱动器上,要输入的完整路径将是D:\SFML\lib
。如果你将 SFML 安装在不同的驱动器上,请更改你的路径。 -
点击 应用 以保存到目前为止的配置。
-
接下来,仍然在同一窗口中,执行以下步骤。将 配置 下拉菜单切换到 调试,因为我们将在调试模式下运行和测试 Pong。
-
选择 链接器 然后选择 输入。
-
找到
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。请格外小心地将光标放在编辑框当前内容的起始位置,以免覆盖任何已存在的文本。 -
点击 确定。
-
点击 应用 然后点击 确定。
现在,你已经配置了项目属性,你几乎准备就绪了。接下来,我们需要按照以下步骤将 SFML .dll
文件复制到主项目目录中:
-
我的主要项目目录是
D:\VS Projects\Zombie Arena
。这个文件夹是在之前的步骤中由 Visual Studio 创建的。如果你将你的Projects
文件夹放在其他地方,那么在你的目录中执行此步骤。我们需要复制到项目文件夹中的文件位于你的SFML\bin
文件夹中。为这两个位置打开一个窗口,并突出显示所有的.dll
文件。 -
现在,将高亮显示的文件复制到项目中。
项目现在已经设置好并准备就绪。接下来,我们将探索并添加项目资源。
项目资源
与之前游戏相比,这个项目中的资源更多样化和丰富。资源包括以下内容:
-
屏幕上文本所需的字体
-
不同动作的音效,如射击、装弹或被僵尸击中
-
角色图形、僵尸图形以及各种背景纹理的精灵图
游戏所需的所有图形和音效都包含在下载包中。它们分别位于 第八章/graphics
和 第八章/sound
文件夹中。
所需的字体尚未提供。这是为了避免任何关于许可的歧义。这不会造成问题,因为将提供下载字体以及如何和在哪里选择字体的链接。
探索资源
图形资源构成了我们僵尸竞技场游戏的场景部分。看看以下图形资源;你应该能清楚地知道游戏中的资源将如何使用:
然而,可能不那么明显的是 background_sheet.png
文件,它包含四幅不同的图像。这是我们之前提到的精灵图集。我们将在 第九章,C++ 参考,精灵图集和顶点数组 中看到如何使用精灵图集来节省内存并提高游戏速度。
所有声音文件都采用 .wav
格式。这些文件包含在触发某些事件时将播放的声音效果。具体如下:
-
hit.wav
:僵尸与玩家接触时播放的声音。 -
pickup.wav
:当玩家碰撞或踩到(收集)健康提升(拾取)时播放的声音。 -
powerup.wav
:当玩家在每一波僵尸之间选择一个属性来增强他们的力量(升级)时播放的声音。 -
reload.wav
:一个令人满意的点击声,让玩家知道他们已经装上了新的弹药。 -
reload_failed.wav
:一个不那么令人满意的音效,表示未能装上新子弹。 -
shoot.wav
:射击声音。 -
splat.wav
:僵尸被子弹击中的声音。
一旦您决定使用哪些资产,就是时候将它们添加到项目中。
将资产添加到项目中
以下说明将假设您正在使用书中提供的下载包中的所有资产。如果您使用自己的资产,只需用您自己的相应声音或图形文件替换,使用相同的文件名。让我们看看步骤:
-
浏览到
D:\VS Projects\ZombieArena
。 -
在此文件夹内创建三个新文件夹,分别命名为
graphics
、sound
和fonts
。 -
从下载包中,将
Chapter 8/graphics
的全部内容复制到D:\VS Projects\ZombieArena\graphics
文件夹。 -
从下载包中,将
Chapter 6/sound
的全部内容复制到D:\VS Projects\ZombieArena\sound
文件夹。 -
现在,在您的网络浏览器中访问
www.1001freefonts.com/zombie_control.font
并下载 Zombie Control 字体。 -
解压下载内容,并将
zombiecontrol.ttf
文件添加到D:\VS Projects\ZombieArena\fonts
文件夹。
现在,是时候考虑面向对象编程如何帮助我们完成这个项目了,然后我们可以开始编写僵尸竞技场的代码。
面向对象编程和僵尸竞技场项目
我们面临的首要问题是当前项目的复杂性。让我们考虑只有一个僵尸的情况;以下是使其在游戏中运行所需的内容:
-
它的水平和垂直位置
-
它的大小
-
它面对的方向
-
每种僵尸类型不同的纹理
-
一个精灵
-
每种僵尸类型不同的速度
-
每种僵尸类型不同的健康值
-
跟踪每种僵尸的类型
-
碰撞检测数据
-
它的智能(追逐玩家),对于每种僵尸类型略有不同
-
一个指示僵尸是活着还是死了的标志
这可能意味着对于一个僵尸就需要十几个变量,而管理一群僵尸则需要每个变量的整个数组。但是,对于机枪的所有子弹、拾取物品以及不同等级的提升呢?简单的 Timber!!!和 Pong 游戏也开始变得难以管理,很容易推测这个更复杂的射击游戏将会更加难以控制!
幸运的是,我们将把在前两个章节中学到的所有面向对象编程技能付诸实践,并学习一些新的 C++技术。
我们将从这个项目开始编写代表玩家的类。
构建玩家类——第一个类
让我们思考一下Player
类需要做什么,以及我们对其的要求。这个类需要知道它能以多快的速度移动,它在游戏世界中的当前位置,以及它有多少健康值。由于Player
类在玩家眼中被表示为一个二维图形角色,这个类将需要一个Sprite
对象和一个Texture
对象。
此外,尽管现在可能不明显,但我们的Player
类也将从了解游戏运行的整体环境的一些细节中受益。这些细节包括屏幕分辨率、组成竞技场的瓦片大小以及当前竞技场的整体大小。
由于Player
类将负责在每一帧中更新自己(就像蝙蝠和球一样),它需要知道玩家在任何给定时刻的意图。例如,玩家当前是否按下了键盘方向键?或者玩家当前是否按下了多个键盘方向键?布尔变量用于确定W、A、S和D键的状态,并将是必不可少的。
很明显,我们将在新类中需要相当多的变量。在学到了所有关于面向对象编程的知识后,我们当然会把这些变量都设置为私有。这意味着在适当的地方,我们必须提供从main
函数访问的权限。
我们将使用大量的 getter 函数以及一些设置对象状态的函数。这些函数数量相当多。这个类中有 21 个函数。一开始,这可能会显得有些令人畏惧,但我们将逐一过目,并会发现它们中的大多数只是设置或获取一个私有变量。
其中只有几个深入的功能:update
,它将从main
函数中每帧被调用一次,以及spawn
,它将处理每次玩家被创建时初始化一些私有变量。然而,正如我们将看到的,它们并没有什么复杂的地方,并且它们都将被详细描述。
进行编码的最佳方式是编写头文件。这将给我们机会看到所有的私有变量并检查所有的函数签名。
小贴士
请密切注意返回值和参数类型,因为这会使理解函数定义中的代码变得容易得多。
编写玩家类头文件
首先右键单击 Player.h
。最后,点击 添加 按钮。我们现在可以开始编写我们第一个类的头文件了。
通过添加声明,包括开闭花括号,然后加上分号来开始编写 Player
类:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Player
{
};
现在,让我们将所有我们的私有成员变量添加到文件中。根据我们之前讨论的内容,看看你是否能弄清楚每个变量将做什么。我们稍后会逐一介绍:
class Player
{
private:
const float START_SPEED = 200;
const float START_HEALTH = 100;
// Where is the player
Vector2f m_Position;
// Of course, we will need a sprite
Sprite m_Sprite;
// And a texture
// !!Watch this space – Interesting changes here soon!!
Texture m_Texture;
// What is the screen resolution
Vector2f m_Resolution;
// What size is the current arena
IntRect m_Arena;
// How big is each tile of the arena
int m_TileSize;
// Which direction(s) is the player currently moving in
bool m_UpPressed;
bool m_DownPressed;
bool m_LeftPressed;
bool m_RightPressed;
// How much health has the player got?
int m_Health;
// What is the maximum health the player can have
int m_MaxHealth;
// When was the player last hit
Time m_LastHit;
// Speed in pixels per second
float m_Speed;
// All our public functions will come next
};
之前的代码声明了所有我们的成员变量。其中一些是常规变量,而另一些是对象。请注意,它们都在类的 private:
部分下,因此不能从类外部直接访问。
此外,请注意我们正在使用命名约定,即给所有非常量变量的名称前缀为 m_
。这个 m_
前缀将在编写函数定义时提醒我们,它们是成员变量,与我们在某些函数中创建的局部变量不同,也与函数参数不同。
所使用的所有变量都非常直接,例如 m_Position
、m_Texture
和 m_Sprite
,分别代表玩家的当前位置、纹理和精灵。除此之外,每个变量(或变量组)都有注释,以便清楚地说明其用法。
然而,为什么它们是必需的,以及它们将用于什么上下文,可能并不那么明显。例如,m_LastHit
是 Time
类型的对象,用于记录玩家最后一次被僵尸击中的时间。我们可能需要这个信息的 why
并不明显,但我们会很快讨论这个问题。
当我们将游戏的其余部分拼凑起来时,每个变量的上下文将变得更加清晰。现在的重要事情是熟悉名称和数据类型,以便轻松地跟随整个项目的其余部分。
小贴士
你不需要记住变量名称和类型,因为当它们被使用时我们会讨论所有代码。然而,你需要花时间仔细查看它们,并更多地熟悉它们。此外,随着我们的进展,如果任何内容似乎不清楚,参考这个头文件可能是有价值的。
现在,我们可以添加一个完整的函数列表。添加以下突出显示的代码,看看你是否能弄清楚它都做了什么。请密切注意返回类型、参数和每个函数的名称。这是理解我们将在这个项目的其余部分编写的代码的关键。它们告诉我们关于每个函数的什么?添加以下突出显示的代码,然后我们将检查它:
// All our public functions will come next
public:
Player();
void spawn(IntRect arena, Vector2f resolution, int tileSize);
// Call this at the end of every game
void resetPlayerStats();
// Handle the player getting hit by a zombie
bool hit(Time timeHit);
// How long ago was the player last hit
Time getLastHitTime();
// Where is the player
FloatRect getPosition();
// Where is the center of the player
Vector2f getCenter();
// What angle is the player facing
float getRotation();
// Send a copy of the sprite to the main function
Sprite getSprite();
// The next four functions move the player
void moveLeft();
void moveRight();
void moveUp();
void moveDown();
// Stop the player moving in a specific direction
void stopLeft();
void stopRight();
void stopUp();
void stopDown();
// We will call this function once every frame
void update(float elapsedTime, Vector2i mousePosition);
// Give the player a speed boost
void upgradeSpeed();
// Give the player some health
void upgradeHealth();
// Increase the maximum amount of health the player can have
void increaseHealthLevel(int amount);
// How much health has the player currently got?
int getHealth();
};
首先,请注意,所有这些函数都是公开的。这意味着我们可以使用main
函数中的类实例调用所有这些函数,代码如下:
player.getSprite();
假设player
是Player
类的完整配置实例,之前的代码将返回m_Sprite
的副本。将此代码放入实际上下文中,我们可以在main
函数中编写如下代码:
window.draw(player.getSprite());
之前的代码会在正确的位置绘制玩家图形,就像在main
函数中直接声明精灵一样。这就是我们在 Pong 项目中使用Bat
类所做的那样。
在我们将要实现(即编写相应的.cpp
文件中的定义)这些函数之前,让我们逐一仔细看看它们:
-
void spawn(IntRect arena, Vector2f resolution, int tileSize)
: 这个函数做它名字暗示的事情。它将准备对象以便使用,包括将其放置在起始位置(即生成)。请注意,它不返回任何数据,但它有三个参数。它接收一个名为arena
的IntRect
实例,这将表示当前级别的尺寸和位置;一个包含屏幕分辨率的Vector2f
实例;以及一个整数,它将包含背景瓷砖的大小。 -
void resetPlayerStats
: 一旦我们赋予玩家在波次之间升级的能力,我们将在新游戏开始时需要能够取消/重置这些能力。 -
Time getLastHitTime()
: 这个函数只做一件事——它返回玩家最后一次被僵尸击中的时间。当检测碰撞时,我们将使用这个函数,并且它将确保玩家不会因为与僵尸接触而频繁受到惩罚。 -
FloatRect getPosition()
: 这个函数返回一个FloatRect
实例,描述了包含玩家图形的矩形的水平和垂直浮点坐标。这对于碰撞检测也很有用。 -
Vector2f getCenter()
: 这与getPosition
略有不同,因为它是一个Vector2f
类型,只包含玩家图形中心的x和y位置。 -
float getRotation()
:main
函数中的代码有时需要知道,以度为单位,玩家当前面向的方向。3 点钟是 0 度,顺时针增加。 -
Sprite getSprite()
: 正如我们之前讨论的,这个函数返回代表玩家的精灵的副本。 -
void moveLeft()
,..Right()
,..Up()
,..Down()
: 这四个函数没有返回类型或参数。它们将从main
函数中调用,然后Player
类将能够在按下一个或多个WASD键时采取行动。 -
void stopLeft()
,..Right()
,..Up()
,..Down()
: 这四个函数没有返回类型或参数。它们将从main
函数中调用,然后Player
类将能够在释放一个或多个WASD键时采取行动。 -
void update(float elapsedTime, Vector2i mousePosition)
: 这将是整个类中唯一的长函数。它将每帧从main
中调用一次。它将执行所有必要的操作,以确保player
对象的数据被更新,以便进行碰撞检测和绘制。注意,它不返回任何数据,但接收自上一帧以来经过的时间量,以及一个Vector2i
实例,它将包含鼠标指针/十字准线的水平和垂直屏幕位置。重要提示
注意,这些是整数屏幕坐标,与浮点世界坐标不同。
-
void upgradeSpeed()
: 一个可以在升级屏幕上调用,当玩家选择让玩家跑得更快时的函数。 -
void upgradeHealth()
: 另一个可以在升级屏幕上调用,当玩家选择让玩家更强(即拥有更多健康)时的函数。 -
void increaseHealthLevel(int amount)
: 与之前的函数相比,这个函数会增加玩家拥有的健康量,直到达到当前设定的最大值。这个函数将在玩家拾取健康物品时使用。 -
int getHealth()
: 由于健康水平是如此动态,我们需要能够确定玩家在任何给定时刻的健康量。这个函数返回一个int
,它包含这个值。
就像变量一样,现在应该很清楚每个函数的作用。同时,使用这些函数的 原因 和精确的上下文也只有在项目进展过程中才会逐渐显现。
小贴士
你不需要记住函数名、返回类型或参数,因为当它们被使用时我们会讨论代码。然而,你需要花时间仔细查看它们,结合之前的解释,并熟悉它们。此外,随着项目的进行,如果任何内容似乎不清楚,参考这个头文件可能会有所帮助。
现在,我们可以继续到函数的核心部分:定义。
编写 Player 类函数定义
最后,我们可以开始编写执行我们类工作的代码。
在 Player.cpp
上 右键点击。最后,点击 添加 按钮。
小贴士
从现在起,我将简单地要求你创建一个新的类或头文件。所以,记住前面的步骤,或者如果需要提醒,请参考这里。
我们现在可以开始为这个项目中第一个类的 .cpp
文件编写代码了。
下面是必要的包含指令,接着是构造函数的定义。记住,构造函数将在我们首次实例化 Player
类型的对象时被调用。将以下代码添加到 Player.cpp
文件中,然后我们可以更仔细地查看它:
#include "player.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
// Set the origin of the sprite to the center,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
在构造函数中,它当然与类名相同且没有返回类型,我们编写代码来开始设置 Player
对象,使其准备好使用。
要清晰明了;此代码将在我们从main
函数中编写以下代码时运行:
Player player;
不要立即添加上一行的代码。
在构造函数中,我们只是从相关常量初始化m_Speed
、m_Health
和m_MaxHealth
。然后,我们将玩家图形加载到m_Texture
中,将m_Texture
与m_Sprite
关联,并将m_Sprite
的原点设置为中心,(25, 25)
。
小贴士
注意到这个神秘的注释// !!Watch this space!!
,它表明我们将返回到加载我们的纹理以及与之相关的一些重要问题。一旦我们发现问题并学习更多 C++,我们最终将改变我们处理这个纹理的方式。我们将在第十章**,指针、标准模板库和纹理管理中这样做。
接下来,我们将编写spawn
函数。我们只会创建一个Player
类的实例。然而,我们需要在每一波中将其生成到当前关卡中。这就是spawn
函数为我们处理的事情。将以下代码添加到Player.cpp
文件中,并确保检查细节并阅读注释:
void Player::spawn(IntRect arena,
Vector2f resolution,
int tileSize)
{
// Place the player in the middle of the arena
m_Position.x = arena.width / 2;
m_Position.y = arena.height / 2;
// Copy the details of the arena
// to the player's m_Arena
m_Arena.left = arena.left;
m_Arena.width = arena.width;
m_Arena.top = arena.top;
m_Arena.height = arena.height;
// Remember how big the tiles are in this arena
m_TileSize = tileSize;
// Store the resolution for future use
m_Resolution.x = resolution.x;
m_Resolution.y = resolution.y;
}
前面的代码首先将m_Position.x
和m_Position.y
的值初始化为传入的arena
高度和宽度的一半。这会将玩家移动到关卡的中心,无论其大小如何。
接下来,我们将传入的arena
的所有坐标和尺寸复制到相同类型的成员对象m_Arena
中。当前竞技场的尺寸和坐标被频繁使用,因此这样做是有意义的。现在我们可以使用m_Arena
来执行诸如确保玩家不能穿过墙壁等任务。此外,我们将传入的tileSize
实例复制到成员变量m_TileSize
中,出于相同的目的。我们将在update
函数中看到m_Arena
和m_TileSize
的实际应用。
前面的代码的最后两行将屏幕分辨率从Vector2f
的resolution
(spawn
的参数)复制到m_Resolution
(Player
的成员变量)。现在我们可以在Player
类内部访问这些值。
现在,添加resetPlayerStats
函数的非常直接的代码:
void Player::resetPlayerStats()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
}
当玩家死亡时,我们将使用此代码来重置他们可能使用的任何升级。
我们不会在接近完成项目之前编写调用resetPlayerStats
函数的代码,但它已经准备好了,以备我们使用。
在代码的下一部分,我们将添加两个额外的函数。它们将处理玩家被僵尸击中的情况。我们将能够调用player.hit()
并传入当前游戏时间。我们还可以通过调用player.getLastHitTime()
来查询玩家最后一次被击中的时间。这些函数的确切用途将在我们有僵尸时变得明显。
将两个新的定义添加到Player.cpp
文件中,然后更仔细地检查 C++代码:
Time Player::getLastHitTime()
{
return m_LastHit;
}
bool Player::hit(Time timeHit)
{
if (timeHit.asMilliseconds()
- m_LastHit.asMilliseconds() > 200)
{
m_LastHit = timeHit;
m_Health -= 10;
return true;
}
else
{
return false;
}
}
getLastHitTime()
的代码非常直接;它将返回存储在m_LastHit
中的任何值。
hit
函数稍微复杂一些,并且更加微妙。首先,if
语句检查传入的参数时间是否比存储在m_LastHit
中的时间晚 200 毫秒。如果是这样,m_LastHit
将更新为传入的时间,m_Health
的当前值将扣除 10 点。if
语句中的最后一行代码是return true
。注意,else
子句只是简单地返回false
给调用代码。
这个函数的整体效果是,玩家的健康点数每秒最多只能扣除五次。记住,我们的游戏循环可能每秒运行数千次迭代。在这种情况下,如果没有这个函数提供的限制,僵尸只需要与玩家接触一秒钟,就会扣除数万健康点数。hit
函数控制并限制这种现象。它还通过返回true
或false
来让调用代码知道是否已注册新的打击(或没有)。
这段代码暗示我们将在main
函数中检测僵尸与玩家之间的碰撞。然后我们将调用player.hit()
来确定是否扣除任何健康点数。
接下来,对于Player
类,我们将实现一系列的 getter 函数。这些函数允许我们保持数据在Player
类中整洁地封装,同时使它们的值对main
函数可用。
在之前的代码块之后添加以下代码:
FloatRect Player::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Vector2f Player::getCenter()
{
return m_Position;
}
float Player::getRotation()
{
return m_Sprite.getRotation();
}
Sprite Player::getSprite()
{
return m_Sprite;
}
int Player::getHealth()
{
return m_Health;
}
之前的代码非常直接。之前的五个函数中的每一个都返回我们成员变量中的一个值。仔细观察每一个,熟悉哪个函数返回哪个值。
下面的八个简短函数启用了键盘控制(我们将在main
函数中使用),以便我们可以更改我们的Player
类型对象的包含数据。将以下代码添加到Player.cpp
文件中,然后我们将总结它是如何工作的:
void Player::moveLeft()
{
m_LeftPressed = true;
}
void Player::moveRight()
{
m_RightPressed = true;
}
void Player::moveUp()
{
m_UpPressed = true;
}
void Player::moveDown()
{
m_DownPressed = true;
}
void Player::stopLeft()
{
m_LeftPressed = false;
}
void Player::stopRight()
{
m_RightPressed = false;
}
void Player::stopUp()
{
m_UpPressed = false;
}
void Player::stopDown()
{
m_DownPressed = false;
}
之前的代码包含四个函数(moveLeft
、moveRight
、moveUp
和moveDown
),这些函数将相关的布尔变量(m_LeftPressed
、m_RightPressed
、m_UpPressed
和m_DownPressed
)设置为true
。另外四个函数(stopLeft
、stopRight
、stopUp
和stopDown
)执行相反的操作,并将相同的布尔变量设置为false
。现在,Player
类的实例可以知道哪些WASD键被按下,哪些没有被按下。
以下函数是完成所有繁重工作的函数。update
函数将在游戏循环的每一帧中调用一次。添加以下代码,然后我们将详细检查它。如果我们跟随着之前的八个函数,并且记得我们是如何为 Timber!!! 项目动画云和蜜蜂,以及为 Pong 项目动画蝙蝠和球的,我们可能会理解以下代码的大部分内容:
void Player::update(float elapsedTime, Vector2i mousePosition)
{
if (m_UpPressed)
{
m_Position.y -= m_Speed * elapsedTime;
}
if (m_DownPressed)
{
m_Position.y += m_Speed * elapsedTime;
}
if (m_RightPressed)
{
m_Position.x += m_Speed * elapsedTime;
}
if (m_LeftPressed)
{
m_Position.x -= m_Speed * elapsedTime;
}
m_Sprite.setPosition(m_Position);
// Keep the player in the arena
if (m_Position.x > m_Arena.width - m_TileSize)
{
m_Position.x = m_Arena.width - m_TileSize;
}
if (m_Position.x < m_Arena.left + m_TileSize)
{
m_Position.x = m_Arena.left + m_TileSize;
}
if (m_Position.y > m_Arena.height - m_TileSize)
{
m_Position.y = m_Arena.height - m_TileSize;
}
if (m_Position.y < m_Arena.top + m_TileSize)
{
m_Position.y = m_Arena.top + m_TileSize;
}
// Calculate the angle the player is facing
float angle = (atan2(mousePosition.y - m_Resolution.y / 2,
mousePosition.x - m_Resolution.x / 2)
* 180) / 3.141;
m_Sprite.setRotation(angle);
}
上述代码的前一部分移动玩家精灵。四个 if
语句检查哪些与移动相关的布尔变量(m_LeftPressed
、m_RightPressed
、m_UpPressed
或 m_DownPressed
)为真,并相应地更改 m_Position.x
和 m_Position.y
。同样,从之前的两个项目中使用的公式来计算移动量也被使用:
位置(+ 或 -)速度 * 经过的时间。
在这四个 if
语句之后,调用 m_Sprite.setPosition
并传入 m_Position
。精灵现在已经调整得恰到好处,以适应那一帧。
接下来的四个 if
语句检查 m_Position.x
或 m_Position.y
是否超出了当前竞技场的任何边缘。记住,当前竞技场的范围存储在 m_Arena
的 spawn
函数中。让我们看看这四个 if
语句中的第一个,以便理解它们:
if (m_Position.x > m_Arena.width - m_TileSize)
{
m_Position.x = m_Arena.width - m_TileSize;
}
上述代码测试 m_position.x
是否大于 m_Arena.width
减去瓦片的大小(m_TileSize
)。当我们创建背景图形时,这个计算将检测玩家是否越界到墙壁。
当 if
语句为真时,使用 m_Arena.width - m_TileSize
的计算来初始化 m_Position.x
。这意味着玩家图形的中心永远不会超出右侧墙壁的左侧边缘。
接下来的三个 if
语句,紧随我们刚刚讨论的那个语句之后,做的是同样的事情,但针对其他三面墙壁。
上述代码的最后两行计算并设置玩家精灵旋转到的角度(即面向)。这一行代码可能看起来有点复杂,但它只是使用准星的位置(mousePosition.x
和 mousePosition.y
)以及屏幕中心(m_Resolution.x
和 m_Resolution.y
)在一个经过验证的三角函数中。
atan
如何使用这些坐标以及 Pi(3.141)相当复杂,这就是为什么它被封装在一个方便的函数中供我们使用。
重要提示
如果你想更详细地探索三角函数,可以在这里进行:www.cplusplus.com/reference/cmath/
。
我们将为 Player
类添加的最后三个函数使玩家速度提高 20%,增加玩家生命值 20%,以及分别增加传入的生命值。
在 Player.cpp
文件的末尾添加以下代码,然后我们将更仔细地查看它:
void Player::upgradeSpeed()
{
// 20% speed upgrade
m_Speed += (START_SPEED * .2);
}
void Player::upgradeHealth()
{
// 20% max health upgrade
m_MaxHealth += (START_HEALTH * .2);
}
void Player::increaseHealthLevel(int amount)
{
m_Health += amount;
// But not beyond the maximum
if (m_Health > m_MaxHealth)
{
m_Health = m_MaxHealth;
}
}
在前面的代码中,upgradeSpeed()
和upgradeHealth()
函数分别增加m_Speed
和m_MaxHealth
中存储的值。这些值通过将起始值乘以.2 并加到当前值上来增加 20%。这些函数将在玩家在关卡之间选择他们希望提高的角色的属性(即升级)时从main
函数中调用。
increaseHealthLevel()
函数从main
中的amount
参数接收一个int
值。这个int
值将由一个名为Pickup
的类提供,我们将在第十一章**碰撞检测、拾取和子弹中编写。m_Health
成员变量会增加传入的值。然而,对于玩家来说有一个限制。if
语句检查m_Health
是否超过了m_MaxHealth
,如果是,则将其设置为m_MaxHealth
。这意味着玩家不能简单地从拾取中获得无限的生命值。相反,他们必须在关卡之间仔细平衡他们选择的升级。
当然,我们的Player
类在实例化并放入游戏循环中工作之前什么都不能做。在我们这样做之前,让我们看看游戏摄像机的概念。
使用 SFML View 控制游戏摄像机
在我看来,SFML View
类是最整洁的类之一。在完成这本书之后,当我们不使用媒体/游戏库制作游戏时,我们真的会注意到View
的缺失。
View
类允许我们将游戏视为在一个具有其自身属性的世界中进行,我的意思是什么?当我们创建游戏时,我们通常试图创建一个虚拟世界。这个虚拟世界很少,如果不是永远,以像素为单位来衡量,而且很少,如果不是永远,这个世界的像素数将与玩家的显示器相同。我们需要一种方法来抽象我们正在构建的虚拟世界,使其可以是我们想要的任何大小或形状。
将 SFML View
视为玩家通过其观看我们虚拟世界一部分的摄像机是另一种思考方式。大多数游戏将拥有多个世界摄像机/视图。
例如,考虑一个分屏游戏,其中两个玩家可以在同一时间在世界中的不同部分。
或者,考虑一个游戏,其中屏幕上的一个小区域代表整个游戏世界,但以非常高的级别/缩放,就像一个迷你地图。
即使我们的游戏比前两个例子简单得多,不需要分屏或迷你地图,我们可能仍然希望创建一个比正在玩的游戏屏幕更大的世界。当然,Zombie Arena 就是这样。
此外,如果我们不断移动游戏摄像机以显示虚拟世界的不同部分(通常是为了跟踪玩家),那么 HUD 会发生什么?如果我们绘制分数和其他屏幕上的 HUD 信息,然后滚动世界以跟随玩家,分数就会相对于摄像机移动。
SFML 的View
类很容易实现所有这些功能,并用非常直接的代码解决了这个问题。诀窍是为每个相机创建一个View
实例——可能是一个用于迷你地图的View
实例,一个用于滚动游戏世界的View
实例,然后是一个用于 HUD 的View
实例。
View
实例可以根据需要移动、调整大小和定位。因此,跟随游戏的main
视图可以跟踪玩家,迷你地图视图可以保持在屏幕的一个固定、缩小的角落,而 HUD 可以覆盖整个屏幕且不会移动,尽管主View
实例可以跟随玩家移动。
让我们看看使用几个View
实例的一些代码。
小贴士
这段代码被用来介绍View
类。不要将此代码添加到僵尸竞技场项目中。
创建并初始化几个View
实例:
// Create a view to fill a 1920 x 1080 monitor
View mainView(sf::FloatRect(0, 0, 1920, 1080));
// Create a view for the HUD
View hudView(sf::FloatRect(0, 0, 1920, 1080));
之前的代码创建了两个填充 1920 x 1080 监视器的View
对象。现在,我们可以用mainView
做一些魔法,同时完全不动hudView
:
// In the update part of the game
// There are lots of things you can do with a View
// Make the view centre around the player
mainView.setCenter(player.getCenter());
// Rotate the view 45 degrees
mainView.rotate(45)
// Note that hudView is totally unaffected by the previous code
当我们操作View
实例的属性时,我们这样做。当我们向视图中绘制精灵、文本或其他对象时,我们必须明确地将视图设置为当前窗口的视图:
// Set the current view
window.setView(mainView);
现在,我们可以将我们想要绘制的一切都绘制到这个视图中:
// Do all the drawing for this view
window.draw(playerSprite);
window.draw(otherGameObject);
// etc
玩家的坐标可能是什么都行;这无关紧要,因为mainView
是围绕图形居中的。
现在,我们可以将 HUD 绘制到hudView
中。注意,就像我们从后往前在层中绘制单个元素(背景、游戏对象、文本等)一样,我们也会从后往前绘制视图。因此,HUD 是在主游戏场景之后绘制的:
// Switch to the hudView
window.setView(hudView);
// Do all the drawing for the HUD
window.draw(scoreText);
window.draw(healthBar);
// etc
最后,我们可以以通常的方式绘制/显示窗口及其当前帧的所有视图:
window.display();
小贴士
如果你想要将你对 SFML View
的理解进一步扩展到这个项目所必需的范围之外,包括如何实现分屏和迷你地图,那么网上最好的指南是官方 SFML 网站:www.sfml-dev.org/tutorials/2.5/graphics-view.php
。
现在我们已经了解了View
,我们可以开始编写僵尸竞技场main
函数,并真正使用我们的第一个View
实例。在第十二章,分层视图和实现 HUD,我们将介绍View
的第二个实例用于 HUD,并将其叠加在主View
实例之上。
启动僵尸竞技场游戏引擎
在这个游戏中,我们在main
中需要一个稍微升级的游戏引擎。我们将有一个名为state
的枚举,它将跟踪游戏当前的状态。然后,在main
的整个过程中,我们可以将我们的代码部分包裹起来,以便在不同的状态下发生不同的事情。
当我们创建项目时,Visual Studio 为我们创建了一个名为ZombieArena.cpp
的文件。这个文件将包含我们的main
函数以及实例化和控制所有类的代码。
我们从现在熟悉的main
函数和一些包含指令开始。注意添加了Player
类的包含指令。
将以下代码添加到ZombieArena.cpp
文件中:
#include <SFML/Graphics.hpp>
#include "Player.h"
using namespace sf;
int main()
{
return 0;
}
之前的代码中没有新内容,除了#include "Player.h"
这一行意味着我们现在可以在代码中使用Player
类。
让我们进一步完善我们的游戏引擎。以下代码做了很多事情。当你添加代码时,务必阅读注释,以了解正在发生的事情。然后我们将更详细地讨论它。
在main
函数的开始处添加以下高亮代码:
int main()
{
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP,
GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
// Get the screen resolution and
// create an SFML window
Vector2f resolution;
resolution.x =
VideoMode::getDesktopMode().width;
resolution.y =
VideoMode::getDesktopMode().height;
RenderWindow window(
VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
// Create a an SFML View for the main action
View mainView(sf::FloatRect(0, 0,
resolution.x, resolution.y));
// Here is our clock for timing everything
Clock clock;
// How long has the PLAYING state been active
Time gameTimeTotal;
// Where is the mouse in
// relation to world coordinates
Vector2f mouseWorldPosition;
// Where is the mouse in
// relation to screen coordinates
Vector2i mouseScreenPosition;
// Create an instance of the Player class
Player player;
// The boundaries of the arena
IntRect arena;
// The main game loop
while (window.isOpen())
{
}
return 0;
}
让我们逐个检查我们输入的所有代码的每个部分。在main
函数内部,我们有以下代码:
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
之前的代码创建了一个名为State
的新枚举类。然后,代码创建了一个名为state
的State
类实例。现在,state
枚举可以是以下四个值之一,如声明中定义的那样。这些值是PAUSED
、LEVELING_UP
、GAME_OVER
和PLAYING
。这四个值正是我们跟踪和响应游戏在任何给定时间可能处于的不同状态所需要的。请注意,state
一次不可能持有多个值。
紧接着,我们添加了以下代码:
// Get the screen resolution and create an SFML window
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
之前的代码声明了一个名为resolution
的Vector2f
实例。我们通过调用VideoMode::getDesktopMode
函数来初始化resolution
的两个成员变量(x
和y
),用于width
和height
。现在,resolution
对象持有游戏运行在的监视器的分辨率。最后一行代码使用适当的分辨率创建了一个名为window
的新RenderWindow
实例。
以下代码创建了一个 SFML View
对象。视图最初位于监视器像素的精确坐标。如果我们使用这个View
在这个当前位置进行绘图,它将等同于在没有视图的窗口中绘图。然而,我们最终将开始移动这个视图,以聚焦于玩家需要看到的游戏世界的部分。然后,当我们开始使用第二个View
实例(用于 HUD 并保持固定)时,我们将看到这个View
实例如何跟踪动作,而另一个保持静态以显示 HUD:
// Create a an SFML View for the main action
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
接下来,我们创建了一个Clock
实例来进行计时,并创建了一个名为gameTimeTotal
的Time
对象,它将记录已经过去的时间。随着项目的进展,我们还将引入更多的变量和对象来处理计时:
// Here is our clock for timing everything
Clock clock;
// How long has the PLAYING state been active
Time gameTimeTotal;
以下代码声明了两个向量:一个包含两个float
变量,称为mouseWorldPosition
,另一个包含两个整数,称为mouseScreenPosition
。鼠标指针有点特殊,因为它存在于两个不同的坐标空间中。如果我们愿意,可以将其视为平行宇宙。首先,当玩家在世界中移动时,我们需要跟踪准星在那个世界中的位置。这些将是浮点坐标,并将存储在mouseWorldCoordinates
中。当然,显示器本身的实际像素坐标永远不会改变。它们始终是 0,0 到水平分辨率-1,垂直分辨率-1。我们将使用存储在mouseScreenPosition
中的整数来跟踪相对于此坐标空间的鼠标指针位置:
// Where is the mouse in relation to world coordinates
Vector2f mouseWorldPosition;
// Where is the mouse in relation to screen coordinates
Vector2i mouseScreenPosition;
最后,我们开始使用我们的Player
类。这一行代码将导致构造函数(Player::Player
)执行。如果您想刷新对这个函数的记忆,请参考Player.cpp
:
// Create an instance of the Player class
Player player;
这个IntRect
对象将包含起始水平和垂直坐标,以及宽度和高度。一旦初始化,我们将能够通过代码如arena.left
、arena.top
、arena.width
和arena.height
来访问当前竞技场的尺寸和位置详情:
// The boundaries of the arena
IntRect arena;
我们之前添加的代码的最后部分当然是我们的游戏循环:
// The main game loop
while (window.isOpen())
{
}
我们可能已经注意到代码变得相当长。我们将在下一节讨论这个不便之处。
管理代码文件
使用类和函数进行抽象的一个优点是,我们的代码文件长度(行数)可以减少。尽管我们将为这个项目使用十几个代码文件,但ZombieArena.cpp
中的代码长度在项目结束时仍会变得有点难以管理。在最终项目 Space Invaders++中,我们将探讨更多抽象和管理代码的方法。
目前,使用这个技巧来保持事情的可管理性。注意,在 Visual Studio 代码编辑器的左侧,有几个+和-符号,其中一个在本图中显示:
每个代码块(例如if
、while
、for
等)将有一个对应的标记。您可以通过点击+和-符号来展开和折叠这些块。我建议将所有当前未讨论的代码块都折叠起来。这将使事情更加清晰。
此外,我们可以创建自己的可折叠块。我建议将主游戏循环开始之前的所有代码制作成一个可折叠块。要做到这一点,请突出显示代码,然后右键单击并选择大纲|隐藏选择,如图所示:
现在,您可以点击 - 和 + 符号来展开和折叠块。每次我们在主游戏循环之前添加代码(这将会很频繁),您都可以展开代码,添加新行,然后再将其折叠。以下截图显示了代码折叠时的样子:
这比之前要容易管理得多。现在,我们可以开始编写主游戏循环。
开始编写主游戏循环
如您所见,前面代码的最后部分是游戏循环(while (window.isOpen()){}
)。我们现在将关注这个部分。具体来说,我们将编写游戏循环的输入处理部分。
我们将要添加的代码相当长。尽管如此,它并没有什么复杂的地方,我们稍后将会详细检查它。
将以下高亮显示的代码添加到游戏循环中:
// The main game loop
while (window.isOpen())
{
/*
************
Handle input
************
*/
// Handle events by polling
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Pause a game while playing
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Restart while paused
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Reset the clock so there isn't a frame jump
clock.restart();
}
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
}
}
}// End event polling
}// End game loop
在前面的代码中,我们实例化了一个 Event
类型的对象。我们将像在之前的项目中一样使用 event
来轮询系统事件。为此,我们将上一个代码块中的其余代码包裹在一个带有 window.pollEvent(event)
条件的 while
循环中。这将保持循环,直到没有更多事件需要处理。
在这个 while
循环内部,我们处理我们感兴趣的的事件。首先,我们测试 Event::KeyPressed
事件。如果游戏处于 PLAYING
状态时按下了 Return
键,那么我们将 state
切换到 PAUSED
。
如果在游戏处于 PAUSED
状态时按下了 Return
键,那么我们将 state
切换到 PLAYING
并重新启动 clock
对象。我们在从 PAUSED
切换到 PLAYING
后重新启动 clock
的原因是,当游戏暂停时,经过的时间仍然会累积。如果我们不重新启动时钟,所有对象都会更新它们的位置,就像帧刚刚花费了很长时间一样。随着我们在文件中完善其余的代码,这一点将变得更加明显。
然后,我们有一个 else if
块来测试在游戏处于 GAME_OVER
状态时是否按下了 Return
键。如果是的话,那么 state
将被更改为 LEVELING_UP
。
重要提示
注意,GAME_OVER
状态是显示主页面的状态。因此,GAME_OVER
状态是在玩家刚刚死亡以及玩家第一次运行游戏后的状态。玩家在每一局游戏中首先要做的事情就是选择一个属性来提升(即升级)。
在前面的代码中,有一个最终的 if
条件来测试状态是否等于 PLAYING
。这个 if
块是空的,我们将在整个项目中向其中添加代码。
小贴士
我们将在整个项目过程中向这个文件的许多不同部分添加代码。因此,花时间了解我们的游戏可能处于的不同状态以及我们如何处理这些状态是非常有价值的。在适当的时候折叠和展开不同的 if
、else
和 while
块也将非常有好处。
花些时间彻底熟悉我们刚刚编写的while
、if
和else if
块。我们将会经常引用它们。
接下来,在之前的代码之后,仍然在游戏循环内,仍然在处理输入,添加以下突出显示的代码。注意现有的代码(未突出显示),它显示了新(突出显示)代码的确切位置:
}// End event polling
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Handle WASD while playing
if (state == State::PLAYING)
{
// Handle the pressing and releasing of the WASD keys
if (Keyboard::isKeyPressed(Keyboard::W))
{
player.moveUp();
}
else
{
player.stopUp();
}
if (Keyboard::isKeyPressed(Keyboard::S))
{
player.moveDown();
}
else
{
player.stopDown();
}
if (Keyboard::isKeyPressed(Keyboard::A))
{
player.moveLeft();
}
else
{
player.stopLeft();
}
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
}// End WASD while playing
}// End game loop
在前面的代码中,我们首先测试玩家是否按下了Escape键。如果按下,游戏窗口将被关闭。
接下来,在一个大的if(state == State::PLAYING)
块内,我们依次检查每个WASD键。如果按键被按下,我们调用相应的player.move...
函数。如果没有,我们调用相关的player.stop...
函数。
此代码确保在每个帧中,玩家对象都会根据按下的WASD键和未按下的键进行更新。player.move...
和player.stop...
函数将信息存储在成员布尔变量中(m_LeftPressed
、m_RightPressed
、m_UpPressed
和m_DownPressed
)。Player
类然后在每个帧的player.update
函数中响应这些布尔值,我们将在游戏循环的更新部分调用它。
现在,我们可以处理键盘输入,允许玩家在每场游戏的开始和每波之间升级。添加并学习以下突出显示的代码,然后我们将讨论它:
}// End WASD while playing
// Handle the LEVELING up state
if (state == State::LEVELING_UP)
{
// Handle the player LEVELING up
if (event.key.code == Keyboard::Num1)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num2)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num3)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num4)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num5)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num6)
{
state = State::PLAYING;
}
if (state == State::PLAYING)
{
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// We will modify this line of code later
int tileSize = 50;
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Reset the clock so there isn't a frame jump
clock.restart();
}
}// End LEVELING up
}// End game loop
在前面的代码中,它全部包含在一个测试中,以查看当前state
的值是否等于LEVELING_UP
,我们处理键盘键1、2、3、4、5和6。在每一个if
块中,我们只是将state
设置为State::PLAYING
。我们将在第十三章**,声音效果、文件 I/O 和完成游戏中稍后添加一些代码来处理每个升级选项。
此代码执行以下操作:
-
如果
state
等于LEVELING_UP
,等待按下1、2、3、4、5或6键。 -
按下时,将
state
更改为PLAYING
。 -
当状态改变时,仍然在
if (state == State::LEVELING_UP)
块内,嵌套的if(state == State::PLAYING)
块将会执行。 -
在此块中,我们设置
arena
的位置和大小,将tileSize
设置为50
,将所有信息传递给player.spawn
,并调用clock.restart
。
现在,我们有一个实际生成的玩家对象,它了解其环境并能对按键做出响应。我们现在可以在循环的每次传递中更新场景。
一定要将游戏循环中输入处理部分的代码整洁地折叠起来,因为我们现在已经完成了这部分。以下代码是游戏循环的更新部分。添加并学习以下突出显示的代码,然后我们可以讨论它:
}// End LEVELING up
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
// Make a note of the players new position
Vector2f playerPosition(player.getCenter());
// Make the view centre around the player
mainView.setCenter(player.getCenter());
}// End updating the scene
}// End game loop
首先,请注意,上一段代码被包裹在一个测试中,以确保游戏处于PLAYING
状态。我们不希望在这段代码在游戏暂停、结束或玩家选择升级时运行。
首先,我们重新启动时钟并将上一帧所花费的时间存储在dt
变量中:
// Update the delta time
Time dt = clock.restart();
接下来,我们将上一帧所花费的时间添加到游戏运行的总累积时间gameTimeTotal
中:
// Update the total game time
gameTimeTotal += dt;
现在,我们使用dt.AsSeconds
函数返回的值初始化一个名为dtAsSeconds
的float
变量。对于大多数帧,这将是一个分数。这对于传递给player.update
函数以计算移动玩家精灵的量是完美的。
现在,我们可以使用MOUSE::getPosition
函数初始化mouseScreenPosition
。
重要提示
你可能想知道获取鼠标位置略微不寻常的语法。这被称为静态函数。如果我们使用static
关键字在类中定义一个函数,我们可以使用类名调用该函数,而不需要类的实例。C++面向对象编程有很多这样的怪癖和规则。随着我们的进展,我们将看到更多。
然后,我们使用window
上的 SFML mapPixelToCoords
函数初始化mouseWorldPosition
。我们在本章前面讨论了该函数。
到目前为止,我们现在能够调用player.update
并传入dtAsSeconds
和鼠标的位置,正如所需的那样。
我们将玩家的新中心存储在一个名为playerPosition
的Vector2f
实例中。目前,这个变量尚未使用,但我们在项目后期将会有所用途。
然后,我们可以使用mainView.setCenter(player.getCenter())
将视图中心定位在玩家最新位置的中央。
我们现在能够将玩家绘制到屏幕上。添加以下突出显示的代码,将主游戏循环的绘制部分拆分为不同的状态:
}// End updating the scene
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the player
window.draw(player.getSprite());
}
if (state == State::LEVELING_UP)
{
}
if (state == State::PAUSED)
{
}
if (state == State::GAME_OVER)
{
}
window.display();
}// End game loop
return 0;
}
在上一段代码的if(state == State::PLAYING)
部分中,我们清除屏幕,将窗口的视图设置为mainView
,然后使用window.draw(player.getSprite())
绘制玩家精灵。
在处理完所有不同的状态后,代码以通常的方式使用window.display();
显示场景。
你可以运行游戏,并看到我们的玩家角色在鼠标移动时旋转。
小贴士
当你运行游戏时,你需要按Enter键开始游戏,然后从1到6选择一个数字来模拟选择升级选项。然后,游戏将开始。
你还可以在(空白的)500 x 500 像素的竞技场内移动玩家。你可以看到屏幕中央的孤独玩家,如图所示:
然而,你无法感受到任何移动的感觉,因为我们还没有实现背景。我们将在下一章这样做。
摘要
呼!这一章内容很长。我们在本章做了很多工作:我们为 Zombie Arena 项目构建了第一个类Player
,并在游戏循环中使用了它。我们还学习了并使用了View
类的一个实例,尽管我们还没有探索这给我们带来的好处。
在下一章中,我们将通过探索精灵图集(sprite sheets)来构建我们的竞技场背景。我们还将学习关于 C++ 引用的知识,这些引用允许我们在变量超出作用域(即在另一个函数中)时对其进行操作。
常见问题解答
Q) 我注意到我们为Player
类编写了很多我们没有使用的函数。为什么会有这种情况?
A) 我们不是反复回到Player
类,而是将整个项目所需的所有代码都添加进来了。到第十三章“音效、文件输入/输出和完成游戏”结束时,我们将充分利用所有这些功能。
第十章:第九章:C++引用、精灵图集和顶点数组
在第四章,“循环、数组、开关、枚举和函数 – 实现游戏机制”中,我们讨论了作用域的概念。这个概念是指函数或代码内部块中声明的变量只在该函数或块中具有作用域(即,可以被看到或使用)。仅使用我们目前所拥有的 C++知识,这可能会导致问题。如果我们需要在main
函数中处理几个复杂的对象,我们会怎么做?这可能意味着所有代码都必须放在main
函数中。
在本章中,我们将探讨 C++ 引用,它允许我们处理那些通常超出作用域的变量和对象。此外,这些引用将帮助我们避免在函数之间传递大型对象,这是一个缓慢的过程。这是因为每次我们这样做时,都必须制作变量或对象的副本。
带着对引用的新知识,我们将探讨 SFML 的VertexArray
类,它允许我们构建一个大型图像,可以通过单个图像文件中的多个部分快速有效地绘制到屏幕上。到本章结束时,我们将拥有一个可缩放的、随机的、滚动的背景,它是通过引用和VertexArray
对象制作的。
在本章中,我们将讨论以下主题:
-
C++引用
-
SFML 顶点数组
-
编写随机滚动的背景
C++引用
当我们将值传递给函数或从函数返回值时,这正是我们所做的——通过值传递/返回。发生的情况是,变量所持有的值的副本被制作并发送到函数,在那里它被使用。
这的重要性有两方面:
-
如果我们希望函数对变量进行永久性更改,这个系统对我们来说毫无用处。
-
当复制值作为参数传递或从函数返回时,会消耗处理能力和内存。对于简单的
int
,甚至可能是一个Sprite
,这微不足道。然而,对于复杂的对象,比如整个游戏世界(或背景),复制过程将严重影响我们的游戏性能。
参考文献是解决这两个问题的方案。引用是一种特殊的变量类型。引用指向另一个变量。以下是一个例子,帮助您更好地理解这一点:
int numZombies = 100;
int& rNumZombies = numZombies;
在前面的代码中,我们声明并初始化了一个名为numZombies
的普通int
变量。然后我们声明并初始化了一个名为rNumZombies
的int
引用。引用操作符&
位于类型之后,表明正在声明一个引用。
小贴士
引用名称前面的r
前缀是可选的,但有助于记住我们正在处理一个引用。
现在,我们有一个名为numZombies
的int
变量,它存储的值是 100,还有一个名为rNumZombies
的int
引用,它指向numZombies
。
我们对 numZombies
所做的任何操作都可以通过 rNumZombies
看到并且我们对 rNumZombies
所做的任何操作实际上是对 numZombies
的操作。看看以下代码:
int score = 10;
int& rScore = score;
score ++;
rScore ++;
在之前的代码中,我们声明了一个名为 score
的 int
变量。接下来,我们声明了一个名为 rScore
的 int
引用,它指向 score
。记住,我们对 score
所做的任何操作都可以被 rScore
看到并且我们对 rScore
所做的任何操作实际上是对 score
的操作。
因此,考虑当我们这样增加 score
时会发生什么:
score ++;
score
变量现在存储的值是 11。除此之外,如果我们输出 rScore
,它也会输出 11。下一行代码如下:
rScore ++;
现在,score
实际上持有值 12,因为我们对 rScore
所做的任何操作实际上是对 score
的操作。
小贴士
如果你想知道 它是如何工作的,那么在下一章中我们将讨论 指针 时会有更多的揭示。简单来说,你可以将引用视为存储在计算机内存中的位置/地址。这个内存中的位置与它所引用的变量存储其值的位置相同。因此,对引用或变量的操作具有完全相同的效果。
目前,更重要的是讨论引用的 原因。使用引用有两个原因,我们已经在前面提到了。这里它们是,再次总结:
-
在另一个函数中更改/读取变量的值/对象,该函数否则不在作用域内。
-
不进行复制(因此更高效)地传递/返回函数。
研究以下代码,然后我们将讨论它:
void add(int n1, int n2, int a);
void referenceAdd(int n1, int n2, int& a);
int main()
{
int number1 = 2;
int number2 = 2;
int answer = 0;
add(number1, number2, answer);
// answer still equals zero because it is passed as a copy
// Nothing happens to answer in the scope of main
referenceAdd(number1, number2, answer);
// Now answer equals 4 because it was passed by reference
// When the referenceAdd function did this:
// answer = num1 + num 2;
// It is actually changing the value stored by answer
return 0;
}
// Here are the two function definitions
// They are exactly the same except that
// the second passes a reference to a
void add(int n1, int n2, int a)
{
a = n1 + n2;
// a now equals 4
// But when the function returns a is lost forever
}
void referenceAdd(int n1, int n2, int& a)
{
a = n1 + n2;
// a now equals 4
// But a is a reference!
// So, it is actually answer, back in main, that equals 4
}
之前的代码以两个函数的原型开始:add
和 referenceAdd
。add
函数接受三个 int
变量,而 referenceAdd
函数接受两个 int
变量和一个 int
引用。
当调用 add
函数时,将 number1
、number2
和 answer
变量传递进去,会制作值的副本,并操作 add
函数中局部的新变量(即 n1
、n2
和 a
)。因此,main
中的 answer
保持为零。
当调用 referenceAdd
函数时,number1
和 number2
再次按值传递。然而,answer
是按引用传递的。当将加到 n2
上的 n1
的值赋给引用 a
时,实际上发生的是这个值被赋回到 main
函数中的 answer
。
很明显,我们永远不会需要为这样简单的事情使用引用。然而,这确实演示了按引用传递的机制。
让我们总结一下我们对引用的了解。
引用总结
之前的代码演示了如何使用引用通过在另一个作用域中的代码来改变变量的值。除了非常方便之外,通过引用传递也非常高效,因为它没有进行复制。我们的例子,即使用引用来传递一个 int
,有点含糊不清,因为作为一个 int
非常小,所以没有真正的效率提升。在本章后面,我们将使用引用来传递整个关卡布局,效率提升将是显著的。
小贴士
关于引用有一个需要注意的地方!您必须在创建引用时将其分配给一个变量。这意味着它并不完全灵活。现在不用担心这个问题。我们将在下一章进一步探讨引用,以及它们更灵活(并且稍微复杂一些)的关系,例如指针。
这对于 int
类型来说在很大程度上是不相关的,但对于一个大型类对象来说可能具有潜在的重要性。在我们本章后面实现僵尸竞技场游戏的滚动背景时,我们将使用这种精确的技术。
SFML 顶点数组和精灵表
我们几乎准备好实现滚动背景了。我们只需要了解 SFML 顶点数组和精灵表。
什么是精灵表?
精灵表是一组图像,可以是动画帧,也可以是包含在一个图像文件中的完全独立的图形。仔细看看这个精灵表,它包含四个单独的图像,这些图像将被用来在我们的僵尸竞技场游戏中绘制背景:
SFML 允许我们将精灵表加载为常规纹理,就像我们在这本书中迄今为止为每个纹理所做的那样。当我们将多个图像作为单个纹理加载时,GPU 可以更高效地处理它。
小贴士
实际上,现代 PC 可以不使用精灵表就处理这四个纹理。然而,学习这些技术是值得的,因为我们的游戏将开始对我们的硬件提出越来越高的要求。
当我们从精灵表中绘制图像时,我们需要确保我们引用的是所需的精灵表部分的精确像素坐标,如下所示:
之前的图例用坐标和它们在精灵表中的位置标记了每个部分/瓦片。这些坐标被称为纹理坐标。我们将在我们的代码中使用这些纹理坐标来绘制所需的正确部分。
什么是顶点数组?
首先,我们需要问,什么是顶点?顶点是一个单独的图形点,即一个坐标。这个点由水平和垂直位置定义。顶点的复数形式是 vertices。因此,顶点数组就是顶点的整个集合。
在 SFML 中,顶点数组中的每个顶点都有一个颜色和相关的附加顶点(即一对坐标),称为纹理坐标。纹理坐标是我们想要在精灵图中使用的图像的位置。稍后,我们将看到我们如何使用单个顶点数组来定位图形和选择精灵图中要显示的每个位置的部分。
SFML 的VertexArray
类可以持有不同类型的顶点集。但每个VertexArray
只能持有一种类型的集。我们使用适合场合的集类型。
视频游戏中的常见场景包括但不限于以下原语类型:
-
点: 每个点一个顶点。
-
线: 每组有两个顶点,定义了线的起点和终点。
-
三角形: 每个点有三个顶点。这是在复杂 3D 模型中最常用(成千上万)的,或者成对使用来创建一个简单的矩形,如精灵。
-
四边形: 每组有四个顶点。这是从精灵图中映射矩形区域的一种方便方式。
我们将在本项目中使用四边形。
从瓷砖构建背景
僵尸竞技场背景将由随机排列的方形图像组成。你可以将这种排列想象成地板上的瓷砖。
在本项目中,我们将使用带有四边形集的顶点数组。每个顶点将是一个由四个顶点组成的集合的一部分(即一个四边形)。每个顶点将定义背景中的一个瓷砖的角落,而每个纹理坐标将根据精灵图中的特定图像持有适当的值。
让我们看看一些代码以帮助我们开始。这不是我们在项目中将使用的确切代码,但它很接近,并允许我们在进行实际实现之前研究顶点数组。
构建顶点数组
就像我们在创建类实例时做的那样,我们声明我们的新对象。以下代码声明了一个新的VertexArray
类型的对象,名为background
:
// Create a vertex array
VertexArray background;
我们希望让我们的VertexArray
实例知道我们将使用哪种类型的原语。记住,点、线、三角形和四边形都有不同数量的顶点。通过将VertexArray
实例设置为特定类型,将能够知道每个原语的开始。在我们的情况下,我们想要四边形。以下是实现这一点的代码:
// What primitive type are we using
background.setPrimitiveType(Quads);
就像常规 C++数组一样,VertexArray
实例需要设置到特定的大小。然而,VertexArray
类比常规数组更灵活。它允许我们在游戏运行时更改其大小。大小可以在声明时配置,但我们的背景需要随着每一波而扩展。VertexArray
类通过resize
函数提供了这种功能。以下是设置我们的竞技场大小为 10x10 瓷砖大小的代码:
// Set the size of the vertex array
background.resize(10 * 10 * 4);
在上一行代码中,第一个 10
是宽度,第二个 10
是高度,4 是四边形中的顶点数。我们本可以直接传递 400,但这样展示计算过程可以使我们清楚我们在做什么。当我们真正编写项目代码时,我们将进一步采取步骤以增加清晰度,并为计算的每个部分声明变量。
现在,我们有一个 VertexArray
实例,可以配置其数百个顶点。以下是我们在前四个顶点上设置位置坐标的方法(即第一个四边形):
// Position each vertex in the current quad
background[0].position = Vector2f(0, 0);
background[1].position = Vector2f(49, 0);
background[2].position = Vector2f(49,49);
background[3].position = Vector2f(0, 49);
这里展示了我们如何将这些相同顶点的纹理坐标设置到精灵图中的第一张图片。这些坐标在图片文件中是从 0,0(在左上角)到 49,49(在右下角):
// Set the texture coordinates of each vertex
background[0].texCoords = Vector2f(0, 0);
background[1].texCoords = Vector2f(49, 0);
background[2].texCoords = Vector2f(49, 49);
background[3].texCoords = Vector2f(0, 49);
如果我们想要将纹理坐标设置到精灵图中的第二张图片,我们会像这样编写代码:
// Set the texture coordinates of each vertex
background[0].texCoords = Vector2f(0, 50);
background[1].texCoords = Vector2f(49, 50);
background[2].texCoords = Vector2f(49, 99);
background[3].texCoords = Vector2f(0, 99);
当然,如果我们像这样逐个定义每个顶点,那么即使是简单的 10x10 场地配置也将花费很长时间。
当我们真正实现背景时,我们将设计一组嵌套的 for
循环,遍历每个四边形,随机选择一个背景图片,并分配适当的纹理坐标。
代码需要相当智能。它需要知道何时是边缘瓦片,以便可以使用精灵图中的墙壁图像。它还需要使用适当的变量,这些变量知道精灵图中每个背景瓦片的位置,以及所需竞技场的整体大小。
我们将通过将所有代码放入一个单独的函数和一个单独的文件中来使这种复杂性变得可管理。我们将通过使用 C++ 引用来使 VertexArray
实例在 main
中可用。
我们将在稍后检查这些细节。你可能已经注意到,我们从未在任何地方将纹理(带有顶点数组的精灵图)关联起来。现在让我们看看如何做到这一点。
使用顶点数组绘制
我们可以像加载任何其他纹理一样加载精灵图作为纹理,如下面的代码所示:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
我们可以通过一次调用 draw
来绘制整个 VertexArray
:
// Draw the background
window.draw(background, &textureBackground);
之前的代码比逐个绘制每个瓦片作为单独的精灵要高效得多。
重要提示
在我们继续之前,注意 textureBackground
代码前面的 &
符号看起来有点奇怪。你可能会立即想到这与引用有关。这里发生的事情是我们正在传递 Texture
实例的地址而不是实际的 Texture
实例。我们将在下一章中了解更多关于这一点。
现在我们能够利用我们对引用和顶点数组的了解来实现僵尸竞技场项目的下一阶段。
创建随机生成的滚动背景
在本节中,我们将创建一个函数,在单独的文件中创建背景。我们将通过使用顶点数组引用来确保背景对 main
函数可用(在作用域内)。
由于我们将编写其他与 main
函数共享数据的函数,我们将它们都写入它们自己的 .cpp
文件中。我们将在一个新的头文件中提供这些函数的原型,并将该头文件(通过 #include
指令)包含在 ZombieArena.cpp
中。
为了实现这一点,让我们创建一个新的头文件 ZombieArena.h
。我们现在准备好编写我们新函数的头文件了。
在这个新的 ZombieArena.h
头文件中,添加以下突出显示的代码,包括函数原型:
#pragma once
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
之前的代码允许我们编写一个名为 createBackground
的函数的定义。为了与原型匹配,函数定义必须返回一个 int
值,并接收一个 VertexArray
引用和一个 IntRect
对象作为参数。
现在,我们可以在新的 .cpp
文件中创建一个新的文件,我们将在这个文件中编写函数定义。创建一个名为 CreateBackground.cpp
的新文件。我们现在准备好编写创建我们背景的函数定义了。
将以下代码添加到 CreateBackground.cpp
文件中,然后我们将对其进行审查:
#include "ZombieArena.h"
int createBackground(VertexArray& rVA, IntRect arena)
{
// Anything we do to rVA we are really doing
// to background (in the main function)
// How big is each tile/texture
const int TILE_SIZE = 50;
const int TILE_TYPES = 3;
const int VERTS_IN_QUAD = 4;
int worldWidth = arena.width / TILE_SIZE;
int worldHeight = arena.height / TILE_SIZE;
// What type of primitive are we using?
rVA.setPrimitiveType(Quads);
// Set the size of the vertex array
rVA.resize(worldWidth * worldHeight * VERTS_IN_QUAD);
// Start at the beginning of the vertex array
int currentVertex = 0;
return TILE_SIZE;
}
在之前的代码中,我们写下了函数签名以及标记函数体的开始和结束的大括号。
在函数体内部,我们声明并初始化了三个新的 int
常量来保存我们需要在函数的其余部分引用的值。它们是 TILE_SIZE
、TILE_TYPES
和 VERTS_IN_QUAD
。
TILE_SIZE
常量指的是精灵图中每个瓦片的大小(以像素为单位)。TILE_TYPES
常量指的是精灵图中不同瓦片的数量。我们可以将更多瓦片添加到我们的精灵图中,并更改 TILE_TYPES
以匹配更改,而我们即将编写的代码仍然可以工作。VERTS_IN_QUAD
指的是每个四边形中都有四个顶点。使用这个常量比总是输入数字 4
更不容易出错,因为 4
更不清晰。
我们首先声明并初始化两个 int
变量:worldWidth
和 worldHeight
。这些变量的用途可能看起来很明显。它们的名字揭示了这一点,但值得指出的是,它们指的是世界宽度和高度,以瓦片数量而非像素为单位。worldWidth
和 worldHeight
变量是通过将传入的竞技场的高度和宽度除以 TILE_SIZE
常量来初始化的。
接下来,我们第一次使用我们的引用。记住,我们对 rVA
所做的任何操作实际上都是对在 main
函数中(或当我们编写它时)作用域内的变量所做的操作。
然后,我们使用 rVA.setType
来准备使用四边形的顶点数组,然后通过调用 rVA.resize
来使其达到正确的大小。我们将 resize
函数的参数传递为 worldWidth * worldHeight * VERTS_IN_QUAD
的结果,这正好等于我们在准备完成后顶点数组将拥有的顶点数量。
代码的最后一句声明并初始化 currentVertex
为零。我们将使用 currentVertex
在遍历顶点数组时初始化所有顶点。
现在,我们可以编写嵌套 for
循环的第一部分,该循环将准备顶点数组。添加以下突出显示的代码,并根据我们对顶点数组所了解的内容,尝试弄清楚它做了什么:
// Start at the beginning of the vertex array
int currentVertex = 0;
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Position each vertex in the current quad
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Position ready for the next four vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
我们刚刚添加的代码通过使用嵌套的 for
循环遍历顶点数组,首先遍历前四个顶点:currentVertex + 1
、currentVertex + 2
,依此类推。
我们使用数组表示法访问数组中的每个顶点,例如 rvA[currentVertex + 0]..
,依此类推。使用数组表示法,我们调用 position
函数,rvA[currentVertex + 0].position...
。
向 position
函数传递每个顶点的水平和垂直坐标。我们可以通过组合使用 w
、h
和 TILE_SIZE
来程序化地计算出这些坐标。
在上一段代码的末尾,我们定位 currentVertex
,为嵌套 for
循环的下一轮迭代做准备,通过将其增加四个位置(即加四)来实现,即 currentVertex = currentVertex + VERTS_IN_QUAD
。
当然,所有这些只是设置了我们的顶点坐标;它并没有从精灵图中分配纹理坐标。这是我们接下来要做的。
为了使新代码的位置绝对清晰,我在上下文中展示了它,包括我们刚才写的所有代码。添加并学习以下突出显示的代码:
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Position each vertex in the current quad
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Define the position in the Texture for current quad
// Either grass, stone, bush or wall
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Use the wall texture
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
// Position ready for the next for vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
上述突出显示的代码设置了与每个顶点相关的精灵图中的坐标。注意那个相对较长的 if
条件。该条件检查当前四边形是否是竞技场中非常第一个或最后一个四边形之一。如果是(第一个或最后一个之一),那么这意味着它是边界的一部分。然后我们可以使用一个简单的公式,结合 TILE_SIZE
和 TILE_TYPES
,从精灵图中定位墙纹理。
数组表示法和 texCoords
成员依次初始化,为每个顶点分配精灵图中墙纹理的适当角落。
以下代码被包裹在一个 else
块中。这意味着每次四边形不表示边界/墙砖时,它都会通过嵌套的 for
循环运行。在现有代码中添加以下突出显示的代码,然后我们将检查它:
// Define position in Texture for current quad
// Either grass, stone, bush or wall
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Use the wall texture
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
else
{
// Use a random floor texture
srand((int)time(0) + h * w - h);
int mOrG = (rand() % TILE_TYPES);
int verticalOffset = mOrG * TILE_SIZE;
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
}
// Position ready for the next for vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
上述突出显示的代码首先使用一个公式初始化随机数生成器,该公式在每次循环迭代中都会不同。然后,mOrG
变量被初始化为一个介于 0 和 TILE_TYPES
之间的数字。这正是我们随机选择一个砖块类型所需要的。
重要提示
mOrG
代表“泥或草”。这个名字是任意的。
现在,我们通过将 mOrG
乘以 TileSize
来声明并初始化一个名为 verticalOffset
的变量。我们现在在精灵图中有一个垂直参考点,指向随机选择的当前四边形的纹理的起始高度。
现在,我们使用一个涉及 TILE_SIZE
和 verticalOffset
的简单公式来分配纹理每个角落的确切坐标到相应的顶点。
我们现在可以在游戏引擎中使用我们的新函数了。
使用背景
我们已经完成了复杂的部分,所以接下来的步骤将会很简单。共有三个步骤,如下:
-
创建一个
VertexArray
。 -
在每一波升级后初始化它。
-
在每一帧中绘制它。
将以下加粗的代码添加到声明名为 background
的 VertexArray
实例中,并加载 background_sheet.png
文件作为纹理:
// Create an instance of the Player class
Player player;
// The boundaries of the arena
IntRect arena;
// Create the background
VertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// The main game loop
while (window.isOpen())
添加以下代码来调用 createBackground
函数,传入 background
作为引用和 arena
作为值。注意,在加粗的代码中,我们还修改了初始化 tileSize
变量的方式。请按照所示添加加粗的代码:
if (state == State::PLAYING)
{
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// We will modify this line of code later
// int tileSize = 50;
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Reset the clock so there isn't a frame jump
clock.restart();
}
注意,我们已经替换了 int tileSize = 50
这行代码,因为我们直接从 createBackground
函数的返回值中获取值。
小贴士
为了未来代码的清晰性,你应该删除 int tileSize = 50
这行代码及其相关注释。我只是将其注释掉,以便为新代码提供一个更清晰的上下文。
最后,是时候进行绘制了。这真的很简单。我们只是调用 window.draw
并传递 VertexArray
实例以及 textureBackground
纹理:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// Set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the player
window.draw(player.getSprite());
}
小贴士
如果你想知道 textureBackground
前面那个看起来奇怪的 &
符号是什么意思,那么所有的问题将在下一章中变得清晰。
你现在可以运行游戏了。你将看到以下输出:
在这里,注意玩家精灵如何在竞技场范围内平滑地滑行和旋转。尽管当前 main
函数中的代码绘制了一个小竞技场,但 CreateBackground
函数可以创建任何大小的竞技场。我们将在 第十三章 中看到比屏幕更大的竞技场,声音效果、文件输入/输出和完成游戏。
摘要
在本章中,我们发现了 C++ 引用,它们是作为另一个变量的别名而存在的特殊变量。当我们通过引用而不是值传递变量时,我们对引用所做的任何操作都会影响到调用函数中的变量。
我们还学习了关于顶点数组的内容,并创建了一个充满四边形的顶点数组,用于从精灵图中绘制作为背景的瓦片。
当然,房间里的大象是我们这款僵尸游戏没有僵尸。我们将在下一章通过学习 C++ 指针和标准模板库(STL)来解决这个问题。
常见问题解答
这里有一些可能出现在你脑海中的问题:
Q) 你能再次总结这些引用吗?
A) 您必须立即初始化引用,并且不能将其更改为引用另一个变量。使用引用与函数一起使用,这样您就不是在处理副本。这对效率有好处,因为它避免了复制,并有助于我们更容易地将代码抽象为函数。
Q) 有没有简单的方法来记住使用引用的主要好处?
A) 为了帮助您记住引用的用途,请考虑以下简短的韵文:
* 移动大对象可能会使我们的游戏变得卡顿,*
* 通过引用传递比复制更快。*
第十一章:第十章:指针、标准模板库和纹理管理
在本章中,我们将学习很多内容,并在游戏方面完成很多工作。我们首先将学习 C++的基本主题指针。指针是持有内存地址的变量。通常,指针将持有另一个变量的内存地址。这听起来有点像引用,但我们将看到它们要强大得多,并使用指针来处理不断增长的僵尸群。
我们还将了解标准模板库(STL),这是一个允许我们快速轻松地实现常见数据管理技术的类集合。
一旦我们理解了 STL 的基础知识,我们就能使用这些新知识来管理游戏中的所有纹理,因为如果我们有 1,000 个僵尸,我们真的不希望为每个僵尸加载一个图形副本到 GPU 中。
我们还将深入探讨面向对象编程(OOP),并使用一个静态函数,这是一个不需要类实例就可以调用的类函数。同时,我们将看到如何设计一个类,以确保只有一个实例可以存在。当我们需要确保代码的不同部分使用相同的数据时,这是理想的。
在本章中,我们将涵盖以下主题:
-
学习指针
-
学习 STL(标准模板库)
-
使用静态函数和单例类实现
TextureHolder
类 -
实现指向僵尸群组的指针
-
编辑一些现有的代码以使用
TextureHolder
类来处理玩家和背景
学习指针
指针在学习编写 C++代码时可能会引起挫败感。然而,这个概念很简单。
重要提示
指针是一个变量,它持有内存地址。
就这些了!没有什么需要担心的。可能让初学者感到沮丧的是语法——我们用来处理指针的代码。我们将逐步讲解使用指针的代码的每个部分。然后你可以开始掌握它们的持续过程。
小贴士
在本节中,我们将实际上学习比本项目所需更多的指针知识。在下一个项目中,我们将更多地使用指针。尽管如此,我们只是触及了这个主题的表面。进一步的学习绝对推荐,我们将在最后一章中更多地讨论这一点。
我很少建议通过记忆事实、数字或语法来学习是最好的方法。然而,记忆与指针相关的简短但至关重要的语法可能是有价值的。这将确保信息深深地植入我们的脑海,我们永远不会忘记它。然后我们可以讨论为什么我们需要指针,并检查它们与引用的关系。一个指针的类比可能会有所帮助:
小贴士
如果一个变量是一栋房子,其内容是它持有的值,那么指针就是房子的地址。
在上一章讨论引用时,我们了解到当我们向函数传递值或从函数返回值时,我们实际上是在创建一个全新的房子,但它与之前的房子完全相同。我们正在创建传递给或从函数传递的值的副本。
到目前为止,指针可能开始听起来有点像引用。这是因为它们确实有点像引用。然而,指针要灵活得多,功能更强大,并且有自己的特殊和独特用途。这些特殊和独特用途需要特殊的和独特的语法。
指针语法
与指针相关联的主要运算符有两个。第一个是地址运算符:
&
第二个是解引用运算符:
*
我们现在将探讨我们可以用指针使用这些运算符的不同方式。
你首先会注意到运算符的地址与引用运算符相同。为了给有志于成为 C++游戏程序员的初学者增添麻烦,这些运算符在不同的上下文中做不同的事情。从一开始就了解这一点是非常有价值的。如果你正在查看一些涉及指针的代码,感觉像是要发疯,请记住:
提示
你完全正常!你只需要查看上下文的细节。
现在,你知道如果某事不清楚且立即明显,那不是你的错。指针不清楚且立即明显,但仔细观察上下文将揭示正在发生的事情。
带着需要比之前的语法更注意指针的知识,以及两个运算符(地址和解引用)是什么,我们现在可以开始查看一些真正的指针代码。
提示
确保你在继续之前已经记住了这两个运算符。
声明指针
要声明一个新的指针,我们使用解引用运算符,以及指针将要持有的变量的类型。在我们进一步讨论指针之前,看看以下代码:
// Declare a pointer to hold
// the address of a variable of type int
int* pHealth;
上述代码声明了一个名为 pHealth
的新指针,它可以持有 int
类型变量的地址。注意我说的是可以持有 int
类型的变量。像其他变量一样,指针也需要初始化为一个值,以便正确使用它。
变量名 pHealth
,就像其他变量一样,是任意的。
提示
常规做法是在指针变量的名称前加上 p
前缀。这样,当我们处理指针时,就更容易记住,并且可以区分它们和常规变量。
在解引用运算符周围使用的空白是可选的,因为 C++很少关心语法中的空白。然而,建议使用它,因为它有助于可读性。看看以下三行代码,它们都做了相同的事情。
我们在先前的例子中已经看到了以下格式,解引用运算符紧挨着类型:
int* pHealth;
以下代码显示了解引用运算符两边的空白:
int * pHealth;
以下代码展示了在指针名称旁边使用解引用运算符:
int *pHealth;
值得注意的是这些可能性,这样当你阅读代码时,也许是在网上,你会理解它们都是相同的。在这本书中,我们将始终使用带有解引用运算符靠近类型的第一个选项。
正如常规变量只能成功包含适当类型的数据一样,指针应该只持有适当类型变量的地址。
指向 int
类型的指针不应持有 String
、Zombie
、Player
、Sprite
、float
或任何其他类型的地址,除了 int
。
让我们看看我们如何初始化我们的指针。
初始化指针
接下来,我们将看看如何将变量的地址放入指针中。看看下面的代码:
// A regular int variable called health
int health = 5;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health,
// using the "address of" operator
pHealth = &health;
在前面的代码中,我们声明了一个名为 health
的 int
变量,并将其初始化为 5
。尽管我们之前从未讨论过这个问题,但这个变量必须在我们的计算机内存中的某个地方。它必须有一个内存地址。
我们可以使用 pHealth
和 health
的地址来访问这个地址,如下所示:
pHealth = &health;
我们的 pHealth
指针现在持有常规 int
,health
的地址。
小贴士
在 C++ 术语中,我们说 pHealth
指向 health
。
我们可以通过将 pHealth
传递给函数来使用它,这样函数就可以在 health
上工作,就像我们使用引用一样。
如果我们只是用指针做这些事情,那么就没有必要使用指针了,所以让我们看看如何重新初始化它们。
指针的重新初始化
指针与引用不同,可以被重新初始化以指向不同的地址。看看下面的代码:
// A regular int variable called health
int health = 5;
int score = 0;
// Declare a pointer to hold the address of a variable of type int
int* pHealth;
// Initialize pHealth to hold the address of health
pHealth = &health;
// Re-initialize pHealth to hold the address of score
pHealth = &score;
现在,pHealth
指向 int
类型的变量,score
。
当然,我们的指针名称 pHealth
现在是模糊的,可能应该被称为 pIntPointer
。这里的关键是要理解我们可以这样做重新分配。
在这个阶段,我们实际上并没有使用指针做任何事情,除了简单地指向(持有内存地址)。让我们看看我们如何访问指针指向的地址中存储的值。这将使它们真正有用。
解引用指针
我们知道指针在内存中持有地址。如果我们要在我们的游戏中输出这个地址,比如在我们的 HUD 中,在声明和初始化之后,它可能看起来像这样:9876。
它只是一个值——一个表示内存地址的值。在不同的操作系统和硬件类型中,这些值的范围会有所不同。在本书的上下文中,我们永远不需要直接操作地址。我们只关心指针指向的地址中存储的值。
变量实际使用的地址是在游戏执行时(在运行时)确定的,因此在我们编写游戏代码时,我们无法知道变量的地址以及指针中存储的值。
我们可以通过使用 解引用 操作符来访问指针指向的地址存储的值:
*
以下代码直接通过指针操作一些变量。试着跟上来,然后我们将逐一分析:
小贴士
警告!下面的代码毫无意义(本意如此)。它只是演示了指针的使用。
// Some regular int variables
int score = 0;
int hiScore = 10;
// Declare 2 pointers to hold the addresses of int
int* pIntPointer1;
int* pIntPointer2;
// Initialize pIntPointer1 to hold the address of score
pIntPointer1 = &score;
// Initialize pIntPointer2 to hold the address of hiScore
pIntPointer2 = &hiScore;
// Add 10 to score directly
score += 10;
// Score now equals 10
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20\. A new high score
// Assign the new hi score to hiScore using only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
在前面的代码中,我们声明了两个 int
变量,score
和 hiScore
。然后,我们分别用 0 和 10 的值初始化它们。接下来,我们声明了两个指向 int
的指针。这些是 pIntPointer1
和 pIntPointer2
。我们在声明它们的同时初始化它们,以存储(指向)score
和 hiScore
变量的地址。
接下来,我们以通常的方式给 score
加 10,即 score += 10
。然后,我们可以看到通过在指针上使用解引用操作符,我们可以访问它们指向的地址存储的值。以下代码改变了由 pIntPointer1
指向的变量存储的值:
// Add 10 to score using pIntPointer1
*pIntPointer1 += 10;
// score now equals 20, A new high score
上述代码的最后部分解引用了两个指针,将 pIntPointer1
指向的值赋给 pIntPointer2
指向的值:
// Assign the new hi-score to hiScore with only pointers
*pIntPointer2 = *pIntPointer1;
// hiScore and score both equal 20
score
和 hiScore
现在都等于 20。
指针是多才多艺且强大的
我们可以用指针做很多事情。这里只列出了我们可以做的几个有用的事情。
动态分配的内存
我们之前看到的所有指针都指向作用域仅限于它们创建的函数的内存地址。因此,如果我们声明并初始化一个指向局部变量的指针,当函数返回时,指针、局部变量和内存地址都将消失。它们超出了作用域。
到目前为止,我们一直在使用预先决定的游戏执行前固定数量的内存。此外,我们一直在使用的内存是由操作系统控制的,变量在我们调用和从函数返回时丢失和创建。我们需要一种方法来使用始终在作用域内的内存,直到我们完成它。我们希望有权访问我们可以称之为自己的内存,并对其负责。
当我们声明变量(包括指针)时,它们位于一个称为 栈 的内存区域。还有一个内存区域,尽管它由操作系统分配和控制,但可以在运行时分配。这个其他内存区域被称为 自由存储区,有时也称为 堆。
小贴士
堆上的内存没有特定于某个函数的作用域。函数返回不会删除堆上的内存。
这赋予我们巨大的力量。有了对仅限于我们游戏运行在的计算机资源限制的内存的访问,我们可以规划包含大量对象的游戏。在我们的例子中,我们想要一大群僵尸。然而,蜘蛛侠的叔叔不会犹豫提醒我们,“能力越大,责任越大。”
让我们看看我们如何使用指针利用空闲存储中的内存,以及我们如何在完成使用后将其释放回操作系统。
要创建一个指向堆上值的指针,我们需要一个指针:
int* pToInt = nullptr;
在上一行代码中,我们以我们之前看到的方式声明了一个指针,但由于我们没有初始化它指向一个变量,我们将其初始化为 nullptr
。我们这样做是因为这是一个好的实践。考虑当你甚至不知道它指向什么时解引用一个指针(改变它指向的地址中的值)。这将编程上的射击场,蒙上眼睛,让人转圈,然后告诉他们射击。通过将指针指向空(nullptr
),我们无法对它造成任何伤害。
当我们准备在空闲存储中请求内存时,我们使用 new
关键字,如下面的代码行所示:
pToInt = new int;
pToInt
现在持有空闲存储上空间的内存地址,这个空间正好可以存放一个 int
值。
提示
当程序结束时,任何分配的内存都会被返回。然而,重要的是要意识到,除非我们手动释放,否则这块内存(在我们的游戏执行过程中)永远不会被释放。如果我们继续从空闲存储中获取内存而不归还,最终内存会用尽,游戏将会崩溃。
我们偶尔从空闲存储中取 int
大小的块,不太可能耗尽内存。但如果我们的程序中有一个或多个函数或循环请求内存,并且这个函数或循环在整个游戏过程中定期执行,最终游戏会变慢,然后崩溃。此外,如果我们错误地分配了大量对象到空闲存储,那么这种情况可能会很快发生。
以下代码行将 pToInt
之前指向的空闲存储中的内存归还(删除):
delete pToInt;
现在,之前由 pToInt
指向的内存不再属于我们可以随意操作的范围;我们必须采取预防措施。尽管内存已经被归还给操作系统,但 pToInt
仍然持有这块内存的地址,而这块内存已经不再属于我们。
以下代码行确保 pToInt
不能被用来尝试操作或访问这块内存:
pToInt = nullptr;
提示
如果一个指针指向一个无效的地址,它被称为 野指针 或 悬垂指针。如果你尝试解引用一个悬垂指针,如果你幸运,游戏会崩溃,你会得到一个内存访问违规错误。如果你不幸,你将创建一个难以发现的 bug。此外,如果我们使用在函数生命周期之后仍然存在的空闲存储上的内存,我们必须确保保留一个指向它的指针,否则我们将泄漏内存。
现在,我们可以声明指针并将它们指向在自由存储上分配的新内存。我们可以通过解引用它们来操作和访问它们指向的内存。我们还可以在完成使用后将其返回到自由存储,并且我们知道如何避免悬挂指针。
让我们看看指针的一些更多优点。
将指针传递给函数
为了将指针传递给函数,我们需要编写一个在原型中包含指针的函数,如下面的代码所示:
void myFunction(int *pInt)
{
// Dereference and increment the value stored
// at the address pointed to by the pointer
*pInt ++
return;
}
前面的函数只是解引用了指针,并将存储在指向的地址中的值增加 1。
现在,我们可以使用该函数,并显式地传递变量的地址或另一个指向变量的指针:
int someInt = 10;
int* pToInt = &someInt;
myFunction(&someInt);
// someInt now equals 11
myFunction(pToInt);
// someInt now equals 12
如前述代码所示,在函数内部,我们正在操作来自调用代码的变量,并且可以使用变量的地址或指向该变量的指针来这样做,因为这两个操作本质上是一样的。
指针也可以指向类的实例。
声明和使用对象的指针
指针不仅适用于常规变量。我们还可以声明指向用户定义类型(如我们的类)的指针。这就是我们声明指向 Player
类型对象的指针的方式:
Player player;
Player* pPlayer = &Player;
我们甚至可以直接从指针访问 Player
对象的成员函数,如下面的代码所示:
// Call a member function of the player class
pPlayer->moveLeft()
注意这个微妙但至关重要的区别:使用指向对象的指针而不是直接使用对象来访问函数时,会使用 -> 操作符。在这个项目中,我们不需要使用对象的指针,但在做之前,我们将更仔细地探索它们,这将在下一个项目中进行。
在我们谈论一些全新的内容之前,让我们再回顾一个关于指针的新话题。
指针和数组
数组和指针有一些共同之处。数组的名称是一个内存地址。更具体地说,数组的名称是该数组第一个元素的内存地址。为了更清楚地说明这一点,我们可以阅读以下示例。
我们可以创建一个指向数组所持类型的指针,然后使用该指针,使用与数组完全相同的语法:
// Declare an array of ints
int arrayOfInts[100];
// Declare a pointer to int and initialize it
// with the address of the first
// element of the array, arrayOfInts
int* pToIntArray = arrayOfInts;
// Use pToIntArray just as you would arrayOfInts
arrayOfInts[0] = 999;
// First element of arrayOfInts now equals 999
pToIntArray[0] = 0;
// First element of arrayOfInts now equals 0
这也意味着一个原型接受指针的函数也接受指向其指向类型的数组的指针。当我们在构建我们不断增长的僵尸群时,我们将使用这个事实。
小贴士
关于指针和引用之间的关系,编译器实际上在实现我们的引用时使用指针。这意味着引用只是一个方便的工具(在底层使用指针)。你可以把引用想象成一个自动变速器,它在市区驾驶时既好又方便,而指针则是一个手动变速器——更复杂,但正确使用时,它们可以提供更好的结果/性能/灵活性。
指针总结
指针有时有点棘手。实际上,我们对指针的讨论只是对这个主题的介绍。要想对它们感到舒适,唯一的方法就是尽可能多地使用它们。为了完成这个项目,你需要了解关于指针的以下内容:
-
指针是存储内存地址的变量。
-
我们可以将指针传递给函数,以便在调用函数的作用域内直接操作被调用函数中的值。
-
数组名称持有第一个元素的内存地址。我们可以将这个地址作为指针传递,因为这正是它的作用。
-
我们可以使用指针来指向空闲存储器上的内存。这意味着在游戏运行时,我们可以动态地分配大量内存。
小贴士
还有更多使用指针的方法。一旦我们习惯了使用常规指针,我们将在最终项目中学习关于智能指针的内容。
在我们再次开始编码僵尸竞技场项目之前,还有一个主题需要介绍。
标准模板库
标准模板库(STL)是一组数据容器和操作这些容器中数据的途径。如果我们想更具体一点,它是一种存储和操作不同类型的 C++变量和类的方法。
我们可以将不同的容器视为定制和更高级的数组。STL 是 C++的一部分。它不是像 SFML 那样需要设置的选项。
标准模板库(STL)是 C++的一部分,因为其容器以及操作它们的代码对于许多应用程序需要使用的许多类型的代码来说是基本的。
简而言之,STL 实现了我们和几乎所有 C++程序员几乎注定需要使用的代码,至少在某些时候,而且可能相当频繁。
如果我们编写自己的代码来包含和管理我们的数据,那么我们不太可能写得像编写 STL 的人那样高效。
因此,通过使用 STL,我们保证我们正在使用管理我们数据可能写出的最佳代码。甚至 SFML 也使用 STL。例如,在底层,VertexArray
类使用 STL。
我们需要做的只是从可用的容器中选择正确的类型。通过 STL 可用的容器类型包括以下几种:
-
向量:这就像是一个带有助推器的数组。它处理动态调整大小、排序和搜索。这可能是最有用的容器。
-
列表:一个允许对数据进行排序的容器。
-
映射:一个关联容器,允许用户将数据作为键/值对存储。这是其中一块数据是找到另一块数据的“键”。映射也可以增长和缩小,以及进行搜索。
-
集合:一个保证每个元素都是唯一的容器。
重要提示
要获取 STL 容器类型的完整列表、它们的用途和解释,请查看以下链接:
www.tutorialspoint.com/cplusplus/cpp_stl_tutorial.htm
。
在僵尸竞技场游戏中,我们将使用 map
。
小贴士
如果你想窥视 STL 为我们节省的复杂性的类型,那么请查看这个教程,它实现了列表会执行的那种类型的事情。请注意,该教程仅实现了列表的最简单裸骨实现:www.sanfoundry.com/cpp-program-implement-single-linked-list/
。
如果我们探索 STL,我们可以节省大量时间,并最终制作出更好的游戏。让我们更仔细地看看如何使用 Map
实例,然后我们将看到它在僵尸竞技场游戏中将如何对我们有用。
什么是 map
?
与 STL 中的其他容器相比,map
类的特殊之处在于我们访问其中数据的方式。
map
实例中的数据以对的形式存储。考虑一种情况,你使用用户名和密码登录账户。map
对于查找用户名然后检查关联密码的值来说非常完美。
map
对于诸如账户名称和号码,或者公司名称和股价等事物来说也非常合适。
注意,当我们从 STL 中使用 map
时,我们决定构成键值对的值的类型。这些值可以是 string
实例和 int
实例,例如账户号码;string
实例和其他 string
实例,例如用户名和密码;或者用户定义的类型,如对象。
以下是一些真正的代码,让我们熟悉 map
。
声明 map
这就是我们如何声明一个 map
:
map<string, int> accounts;
上一行代码声明了一个新的 map
,名为 accounts
,它有一个 string
对象键,每个键都将指向一个 int
类型的值。
我们现在可以存储 string
类型的键值对,这些键值对指向 int
类型的值。我们将在下一节中看到我们如何做到这一点。
向 Map
中添加数据
让我们继续添加一个键值对到账户中:
accounts["John"] = 1234567;
现在,在 map
中有一个条目,可以使用键 John
访问。以下代码向账户 map
中添加了两个额外的条目:
accounts["Smit"] = 7654321;
accounts["Larissa"] = 8866772;
我们的 map
中有三个条目。让我们看看我们如何访问账户号码。
在 map
中查找数据
我们将使用与添加数据相同的方式访问数据:使用键。例如,我们可以将存储在 Smit
键中的值赋给一个新的 int
,accountNumber
,如下所示:
int accountNumber = accounts["Smit"];
int
变量 accountNumber
现在存储的值是 7654321
。我们可以对存储在 map
实例中的值做任何我们可以对该类型做的事情。
从 map
中删除数据
从我们的 map
中取出值也是直接的。以下代码行移除了键 John
和其关联的值:
accounts.erase("John");
让我们看看我们可以用map
做的一些其他事情。
检查映射的大小
我们可能想知道我们的映射中有多少键值对。以下代码行正是这样做的:
int size = accounts.size();
int
变量size
现在持有2
的值。这是因为accounts
包含了 Smit 和 Larissa 的值,因为我们删除了 John。
在映射中检查键
map
最相关的特性是它能够使用键来查找值。我们可以这样测试特定键的存在性:
if(accounts.find("John") != accounts.end())
{
// This code won't run because John was erased
}
if(accounts.find("Smit") != accounts.end())
{
// This code will run because Smit is in the map
}
在之前的代码中,!= accounts.end
的值用于确定键是否存在。如果搜索的键不在map
中,那么accounts.end
将是if
语句的结果。
让我们看看我们如何通过遍历映射来测试或使用映射中的所有值。
遍历/迭代映射的键值对
我们已经看到如何使用for
循环遍历数组的所有值。但是,如果我们想对映射做类似的事情怎么办?
以下代码展示了我们如何遍历账户的map
中的每个键值对,并将每个账户号码增加1
:
for (map<string,int>::iterator it = accounts.begin();
it != accounts.end();
++ it)
{
it->second += 1;
}
for
循环的条件可能是之前代码中最有趣的部分。条件的第一部分是最长的部分。map<string,int>::iterator it = accounts.begin()
如果分解开来会更容易理解。
map<string,int>::iterator
是一个类型。我们声明了一个适合map
的iterator
,其键值对为string
和int
。迭代器的名称是it
。我们将accounts.begin()
返回的值赋给it
。现在,迭代器it
持有accounts
映射的第一个键值对。
for
循环的其余条件如下。it != accounts.end()
意味着循环将继续,直到达到映射的末尾,而++it
则简单地移动到映射中的下一个键值对,每次循环通过。
在for
循环内部,it->second
访问键值对的值并将值增加1
。注意,我们可以使用it->first
访问键(键值对的第一部分)。
你可能已经注意到,设置映射循环的语法相当冗长。C++有一种方法可以减少这种冗长。
auto
关键字
for
循环条件中的代码相当冗长——特别是在map<string,int>::iterator
方面。C++提供了一个使用auto
关键字来减少冗长的简洁方法。使用auto
关键字,我们可以改进之前的代码:
for (auto it = accounts.begin(); it != accounts.end(); ++ it)
{
it->second += 1;
}
auto
关键字指示编译器为我们自动推断类型。这将在我们编写的下一个类中特别有用。
STL 总结
就像本书中我们讨论的几乎所有 C++ 概念一样,STL 是一个庞大的主题。已经有许多书籍专门介绍 STL。然而,到目前为止,我们已经足够了解如何构建一个使用 STL map
来存储 SFML Texture
对象的类。然后我们可以通过使用文件名作为键值对的键来检索/加载纹理。
为什么我们会选择这种额外的复杂度,而不是像以前一样继续使用 Texture
类,随着我们的深入探讨将会变得清晰。
TextureHolder
类
数千个僵尸代表了一个新的挑战。不仅加载、存储和处理数千个不同僵尸纹理的副本会占用大量内存,还会消耗大量处理能力。我们将创建一种新的类类型,以克服这个问题,并允许我们只存储每种纹理的一个副本。
我们还将以这种方式编写类,以确保只能有一个其实例。这种类型的类被称为 单例。
小贴士
单例是一种设计模式。设计模式是一种结构化我们代码的方式,它已被证明是有效的。
此外,我们还将编写这个类,使其可以在游戏代码的任何地方直接通过类名使用,而不需要访问其实例。
编写 TextureHolder 头文件
让我们创建一个新的头文件。右键点击 TextureHolder.h
。
将以下代码添加到 TextureHolder.h
文件中,然后我们可以讨论它:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
using namespace sf;
using namespace std;
class TextureHolder
{
private:
// A map container from the STL,
// that holds related pairs of String and Texture
map< string, Texture> m_Textures;
// A pointer of the same type as the class itself
// the one and only instance
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static Texture& GetTexture(string const& filename);
};
#endif
在前面的代码中,请注意我们有一个来自 STL 的 map
的 include
指令。我们声明了一个 map
实例,它持有 string
类型以及 SFML Texture
类型,以及键值对。这个 map
被称为 m_Textures
。
在前面的代码中,这一行紧随其后:
static TextureHolder* m_s_Instance;
前一行代码非常有趣。我们正在声明一个指向 TextureHolder
类型对象的静态指针 m_s_Instance
。这意味着 TextureHolder
类有一个与自身类型相同的对象。不仅如此,因为它静态,所以可以通过类本身使用,而不需要类的实例。当我们编写相关的 .cpp
文件时,我们将看到如何使用它。
在类的 public
部分中,我们有构造函数的原型 TextureHolder
。构造函数不接受任何参数,并且,像往常一样,没有返回类型。这与默认构造函数相同。我们将用定义来覆盖默认构造函数,使我们的单例按我们的意愿工作。
我们还有一个名为 GetTexture
的函数。让我们再次查看签名并分析到底发生了什么:
static Texture& GetTexture(string const& filename);
首先,请注意函数返回一个Texture
的引用。这意味着GetTexture
将返回一个引用,这是高效的,因为它避免了复制可能很大的图形。此外,请注意函数被声明为static
。这意味着函数可以在没有类实例的情况下使用。函数接受一个作为参数的string
常量引用。这种效果有两方面。首先,操作是高效的,其次,因为引用是常量,所以不能被更改。
编写 TextureHolder 函数定义
现在,我们可以创建一个新的.cpp
文件,它将包含函数定义。这将使我们能够看到我们新类型函数和变量的原因。右键单击TextureHolder.cpp
。最后,点击添加按钮。我们现在可以开始编写类的代码了。
添加以下代码,然后我们可以讨论它:
#include "TextureHolder.h"
// Include the "assert feature"
#include <assert.h>
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
在之前的代码中,我们将TextureHolder
类型的指针初始化为nullptr
。在构造函数中,assert(m_s_Instance == nullptr)
确保m_s_Instance
等于nullptr
。如果不等于,游戏将退出执行。然后,m_s_Instance = this
将指针赋给this
实例。现在,考虑这段代码的位置。代码位于构造函数中。构造函数是我们从类创建对象实例的方式。因此,实际上我们现在有一个指向TextureHolder
的指针,它指向唯一的实例本身。
将代码的最后一部分添加到TextureHolder.cpp
文件中。这里注释比代码多。在添加代码的同时阅读以下注释,然后我们可以一起过一遍:
Texture& TextureHolder::GetTexture(string const& filename)
{
// Get a reference to m_Textures using m_s_Instance
auto& m = m_s_Instance->m_Textures;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in file name
auto keyValuePair = m.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end())
{
// Yes
// Return the texture,
// the second part of the kvp, the texture
return keyValuePair->second;
}
else
{
// File name not found
// Create a new key value pair using the filename
auto& texture = m[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
// Return the texture to the calling code
return texture;
}
}
你可能首先注意到的上一段代码中的是auto
关键字。auto
关键字在上一节中已经解释过了。
小贴士
如果你想知道被auto
替换的实际类型,那么请查看上一段代码中每次使用auto
之后的注释。
在代码的开始部分,我们获取对m_textures
的引用。然后,我们尝试获取传递的文件名(filename
)表示的键值对的迭代器。如果我们找到一个匹配的键,我们通过return keyValuePair->second
返回纹理。否则,我们将纹理添加到映射中,然后将其返回给调用代码。
诚然,TextureHolder
类引入了许多新的概念(单例、static
函数、常量引用、this
和auto
关键字)和语法。再加上我们刚刚才学习了指针和 STL,这一部分的代码可能有点令人望而生畏。
那么,这一切都值得吗?
我们通过 TextureHolder 实现了什么?
重点是,现在我们有了这个类,我们可以在代码的任何地方随意使用纹理,而不用担心内存不足或无法访问特定函数或类中的任何纹理。我们很快就会看到如何使用TextureHolder
。
构建僵尸群
现在,我们有了TextureHolder
类来确保我们的僵尸纹理可以轻松获取,并且只加载到 GPU 一次。然后,我们可以调查创建一大群僵尸的方法。
我们将使用数组来存储僵尸。由于构建和孵化一大群僵尸的过程涉及相当多的代码行,因此将其抽象为单独的函数是一个很好的选择。很快,我们将编写CreateHorde
函数,但当然,我们首先需要一个Zombie
类。
编写Zombie.h
文件
构建表示僵尸的类的第一步是在头文件中编写成员变量和函数原型。
右键点击Zombie.h
。
将以下代码添加到Zombie.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Zombie
{
private:
// How fast is each zombie type?
const float BLOATER_SPEED = 40;
const float CHASER_SPEED = 80;
const float CRAWLER_SPEED = 20;
// How tough is each zombie type
const float BLOATER_HEALTH = 5;
const float CHASER_HEALTH = 1;
const float CRAWLER_HEALTH = 3;
// Make each zombie vary its speed slightly
const int MAX_VARRIANCE = 30;
const int OFFSET = 101 - MAX_VARRIANCE;
// Where is this zombie?
Vector2f m_Position;
// A sprite for the zombie
Sprite m_Sprite;
// How fast can this one run/crawl?
float m_Speed;
// How much health has it got?
float m_Health;
// Is it still alive?
bool m_Alive;
// Public prototypes go here
};
之前的代码声明了Zombie
类的所有私有成员变量。在之前代码的顶部,我们有三个常量变量来存储每种类型僵尸的速度:一个非常慢的爬行者、一个稍微快一点的膨胀者,以及一个速度较快的追逐者。我们可以通过实验这三个常量的值来帮助平衡游戏的难度级别。这里也值得提一下,这三个值仅作为每种僵尸类型速度的起始值。正如我们将在本章后面看到的那样,我们将根据这些值对每个僵尸的速度进行小百分比的调整。这可以防止同类型的僵尸在追逐玩家时聚集在一起。
接下来的三个常量决定了每种僵尸的健康水平。请注意,膨胀者是最坚韧的,其次是爬行者。为了平衡,追逐者僵尸将是最容易杀死的。
接下来,我们还有两个额外的常量,MAX_VARRIANCE
和OFFSET
。这些将帮助我们确定每个僵尸的个体速度。我们将在编写Zombie.cpp
文件时看到具体是如何实现的。
在这些常量之后,我们声明了一堆看起来很熟悉的变量,因为我们之前在Player
类中也有非常相似的变量。m_Position
、m_Sprite
、m_Speed
和m_Health
变量分别对应它们的名字所暗示的内容:僵尸对象的位置、精灵、速度和健康。
最后,在之前的代码中,我们声明了一个名为m_Alive
的布尔值,当僵尸存活并正在狩猎时为真,而当其健康值降至零时为假,此时它只是我们原本漂亮的背景上的一滩血。
现在,我们可以完成Zombie.h
文件。添加以下代码中突出显示的函数原型,然后我们将讨论它们:
// Is it still alive?
bool m_Alive;
// Public prototypes go here
public:
// Handle when a bullet hits a zombie
bool hit();
// Find out if the zombie is alive
bool isAlive();
// Spawn a new zombie
void spawn(float startX, float startY, int type, int seed);
// Return a rectangle that is the position in the world
FloatRect getPosition();
// Get a copy of the sprite to draw
Sprite getSprite();
// Update the zombie each frame
void update(float elapsedTime, Vector2f playerLocation);
};
在之前的代码中,有一个hit
函数,每次僵尸被子弹击中时都可以调用。该函数可以采取必要的步骤,例如从僵尸那里减去健康值(减少m_Health
的值)或将其杀死(将m_Alive
设置为假)。
isAlive
函数返回一个布尔值,让调用代码知道僵尸是活着还是死了。我们不希望执行碰撞检测或从玩家那里移除健康值,因为玩家走过血迹。
spawn
函数接受一个起始位置、一个类型(爬行者、膨胀者或追击者,由一个int
表示),以及用于某些随机数生成的一个种子,这些随机数生成将在下一节中看到。
就像我们在Player
类中做的那样,Zombie
类有getPosition
和getSprite
函数来获取表示僵尸占据的空间和每帧可以绘制的精灵的矩形。
上一段代码中的最后一个原型是update
函数。我们可能已经猜到它将接收自上一帧以来的经过时间,但请注意,它还接收一个名为playerLocation
的Vector2f
向量。这个向量确实将是玩家中心的精确坐标。我们很快就会看到我们如何使用这个向量来追逐玩家。
现在,我们可以在.cpp
文件中编写函数定义。
编写Zombie.cpp
文件
接下来,我们将编写Zombie
类的功能——函数定义。
创建一个新的.cpp
文件,该文件将包含函数定义。右键单击Zombie.cpp
。最后,单击添加按钮。我们现在准备好编写类了。
将以下代码添加到Zombie.cpp
文件中:
#include "zombie.h"
#include "TextureHolder.h"
#include <cstdlib>
#include <ctime>
using namespace std;
首先,我们添加必要的包含指令,然后using namespace std
。你可能记得我们曾经在前缀我们的对象声明中使用std::
。这个using
指令意味着我们不需要在这个文件中的代码中这样做。
现在,添加以下代码,这是spawn
函数的定义。在你添加了代码之后,请研究一下代码,然后我们将讨论它:
void Zombie::spawn(float startX, float startY, int type, int seed)
{
switch (type)
{
case 0:
// Bloater
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/bloater.png"));
m_Speed = BLOATER_SPEED;
m_Health = BLOATER_HEALTH;
break;
case 1:
// Chaser
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/chaser.png"));
m_Speed = CHASER_SPEED;
m_Health = CHASER_HEALTH;
break;
case 2:
// Crawler
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/crawler.png"));
m_Speed = CRAWLER_SPEED;
m_Health = CRAWLER_HEALTH;
break;
}
// Modify the speed to make the zombie unique
// Every zombie is unique. Create a speed modifier
srand((int)time(0) * seed);
// Somewhere between 80 and 100
float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
// Express this as a fraction of 1
modifier /= 100; // Now equals between .7 and 1
m_Speed *= modifier;
// Initialize its location
m_Position.x = startX;
m_Position.y = startY;
// Set its origin to its center
m_Sprite.setOrigin(25, 25);
// Set its position
m_Sprite.setPosition(m_Position);
}
函数首先执行的是基于传入参数的int
值的执行路径switch
。在switch
块中,为每种僵尸类型都有一个case
。根据僵尸的类型,适当的纹理、速度和健康值被初始化到相关的成员变量中。
小贴士
我们本可以使用枚举来表示不同的僵尸类型。项目完成后,请随意升级您的代码。
这里值得注意的是,我们使用静态的TextureHolder::GetTexture
函数来分配纹理。这意味着无论我们生成多少僵尸,GPU 内存中最多只有三个纹理。
下面的三行代码(不包括注释)执行以下操作:
-
使用作为参数传入的
seed
变量来初始化随机数生成器。 -
使用
rand
函数和MAX_VARRIANCE
和OFFSET
常量声明并初始化modifier
变量。结果是介于零和一之间的分数,可以用来使每个僵尸的速度独特。我们想要这样做的原因是,我们不希望僵尸太多地堆叠在一起。 -
现在,我们可以将
m_Speed
乘以modifier
,这样我们就会有一个速度在定义此类僵尸速度的常数的MAX_VARRIANCE
百分比范围内的僵尸。
在我们解决了速度之后,我们将传入的位置startX
和startY
分别赋值给m_Position.x
和m_Position.y
。
前一个代码列表中的最后两行将精灵的原点设置为中心,并使用m_Position
向量设置精灵的位置。
现在,将以下hit
函数的代码添加到Zombie.cpp
文件中:
bool Zombie::hit()
{
m_Health--;
if (m_Health < 0)
{
// dead
m_Alive = false;
m_Sprite.setTexture(TextureHolder::GetTexture(
"graphics/blood.png"));
return true;
}
// injured but not dead yet
return false;
}
hit
函数很简单:将m_Health
减一,然后检查m_Health
是否低于零。
如果温度低于零,则将m_Alive
设置为 false,将僵尸的纹理更换为血迹,并返回true
给调用代码,以便它知道僵尸现在已死亡。如果僵尸幸存,则击中返回false
。
添加以下三个获取器函数,它们只是将值返回给调用代码:
bool Zombie::isAlive()
{
return m_Alive;
}
FloatRect Zombie::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Zombie::getSprite()
{
return m_Sprite;
}
前三个函数相当直观,也许除了getPosition
函数之外,该函数使用m_Sprite.getLocalBounds
函数来获取FloatRect
实例,然后将其返回给调用代码。
最后,对于Zombie
类,我们需要添加update
函数的代码。仔细查看以下代码,然后我们将逐一分析:
void Zombie::update(float elapsedTime,
Vector2f playerLocation)
{
float playerX = playerLocation.x;
float playerY = playerLocation.y;
// Update the zombie position variables
if (playerX > m_Position.x)
{
m_Position.x = m_Position.x +
m_Speed * elapsedTime;
}
if (playerY > m_Position.y)
{
m_Position.y = m_Position.y +
m_Speed * elapsedTime;
}
if (playerX < m_Position.x)
{
m_Position.x = m_Position.x -
m_Speed * elapsedTime;
}
if (playerY < m_Position.y)
{
m_Position.y = m_Position.y -
m_Speed * elapsedTime;
}
// Move the sprite
m_Sprite.setPosition(m_Position);
// Face the sprite in the correct direction
float angle = (atan2(playerY - m_Position.y,
playerX - m_Position.x)
* 180) / 3.141;
m_Sprite.setRotation(angle);
}
在前面的代码中,我们将playerLocation.x
和playerLocation.y
复制到局部变量playerX
和playerY
中。
接下来,有四个if
语句。它们检查僵尸是否在当前玩家的左侧、右侧、上方或下方。当这四个if
语句评估为真时,将使用通常的公式(即速度乘以上一帧以来经过的时间)适当地调整僵尸的m_Position.x
和m_Position.y
值。更具体地说,代码是m_Speed * elapsedTime
。
在四个if
语句之后,m_Sprite
被移动到其新位置。
然后,我们使用之前与玩家和鼠标指针相同的计算方法,但这次是为僵尸和玩家进行的。这个计算找到了面向玩家的僵尸所需的角度。
最后,对于这个函数和类,我们调用m_Sprite.setRotation
来实际旋转僵尸精灵。记住,这个函数将在游戏的每一帧为每个(存活的)僵尸调用。
但是,我们想要一大群僵尸。
使用Zombie
类创建僵尸群
现在我们有了创建一个有生命、能攻击和可被杀死的僵尸的类,我们想要孵化一大群它们。
为了实现这一点,我们将编写一个单独的函数,并使用指针以便我们可以引用在main
中声明但在不同作用域中配置的我们的僵尸群。
在 Visual Studio 中打开ZombieArena.h
文件,并添加以下突出显示的代码行:
#pragma once
#include "Zombie.h"
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
Zombie* createHorde(int numZombies, IntRect arena);
现在我们有了原型,我们可以编写函数定义。
创建一个新的.cpp
文件,它将包含函数定义。右键单击CreateHorde.cpp
。最后,点击添加按钮。
将以下代码添加到CreateHorde.cpp
文件中,并对其进行研究。之后,我们将将其分解成块并讨论它:
#include "ZombieArena.h"
#include "Zombie.h"
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
for (int i = 0; i < numZombies; i++)
{
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
}
return zombies;
}
让我们再次仔细查看所有之前的代码,分成小块来分析。首先,我们添加了现在熟悉的include
指令:
#include "ZombieArena.h"
#include "Zombie.h"
接下来是函数签名。请注意,该函数必须返回一个指向Zombie
对象的指针。我们将创建一个Zombie
对象的数组。一旦我们完成僵尸群的创建,我们将返回该数组。当我们返回数组时,我们实际上返回的是数组的第一个元素的地址。正如我们在本章早些时候关于指针的部分所学的,这与指针是相同的。签名还显示我们有两个参数。第一个参数numZombies
将是当前僵尸群所需的僵尸数量,第二个参数arena
是一个IntRect
,它包含创建此僵尸群时当前竞技场的大小。
在函数签名之后,我们声明了一个指向Zombie
类型的指针,名为zombies
,并用堆上动态分配的数组的第一个元素的内存地址对其进行初始化:
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
代码的下一部分简单地将竞技场的边缘复制到maxY
、minY
、maxX
和minX
中。我们在右侧和底部减去二十像素,同时在顶部和左侧加上二十像素。我们使用这四个局部变量来帮助定位每个僵尸。我们进行了二十像素的调整,以防止僵尸出现在墙上:
int maxY = arena.height - 20;
int minY = arena.top + 20;
int maxX = arena.width - 20;
int minX = arena.left + 20;
现在,我们进入一个for
循环,它会遍历zombies
数组中的每个Zombie
对象,从零到numZombies
:
for (int i = 0; i < numZombies; i++)
在for
循环内部,代码首先初始化随机数生成器,然后生成一个介于零和三之间的随机数。这个数字存储在side
变量中。我们将使用side
变量来决定僵尸是在竞技场的左侧、顶部、右侧还是底部孵化。我们还声明了两个int
类型的变量x
和y
。这两个变量将暂时保存当前僵尸的实际水平和垂直坐标:
// Which side should the zombie spawn
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
仍然在for
循环内部,我们有一个包含四个case
语句的switch
块。请注意,case
语句对应于0
、1
、2
和3
,switch
语句中的参数是side
。在每个case
块内部,我们将x
和y
初始化为一个预定的值和一个随机生成的值。仔细观察每个预定值和随机值的组合。你会发现它们适合将当前僵尸随机定位在左侧、顶部、右侧或底部的边缘上。这种效果将使得每个僵尸可以随机孵化,在任何竞技场的外边缘上:
switch (side)
{
case 0:
// left
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// right
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// top
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// bottom
x = (rand() % maxX) + minX;
y = maxY;
break;
}
仍然在for
循环内部,我们再次对随机数生成器进行播种,并生成一个介于 0 和 2 之间的随机数。我们将这个数字存储在type
变量中。type
变量将决定当前僵尸将是追击者、膨胀者还是爬行者。
确定类型后,我们在zombies
数组中的当前Zombie
对象上调用spawn
函数。作为提醒,传递给spawn
函数的参数决定了僵尸的起始位置和它将成为的类型。显然任意的i
作为参数传递,因为它用作一个独特的种子,在适当的范围内随机变化僵尸的速度。这阻止了我们的僵尸“聚集成团”并变成一团而不是一群:
// Bloater, crawler or runner
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Spawn the new zombie into the array
zombies[i].spawn(x, y, type, i);
for
循环对每个僵尸重复一次,由numZombies
中包含的值控制,然后我们返回数组。再次提醒,数组仅仅是它自己的第一个元素地址。数组是在堆上动态分配的,因此在函数返回后仍然存在:
return zombies;
现在,我们可以让我们的僵尸复活。
让僵尸群复活(再次复活)
我们有一个Zombie
类和一个用于生成随机出现的僵尸群的函数。我们有一个TextureHolder
单例,作为一种巧妙的方式来保存仅用于数十个甚至数千个僵尸的三个纹理。现在,我们可以在main
中将僵尸群添加到我们的游戏引擎中:
添加以下高亮代码来包含TextureHolder
类。然后,就在main
函数内部,我们将初始化唯一的TextureHolder
实例,它可以在我们的游戏中的任何地方使用:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
using namespace sf;
int main()
{
// Here is the instance of TextureHolder
TextureHolder holder;
// The game will always be in one of four states
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Start with the GAME_OVER state
State state = State::GAME_OVER;
以下几行高亮代码声明了一些控制变量,用于波次开始时的僵尸数量、仍需被杀死的僵尸数量,以及当然,一个指向Zombie
的指针zombies
,我们将其初始化为nullptr
:
// Create the background
VertexArray background;
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = nullptr;
// The main game loop
while (window.isOpen())
接下来,在PLAYING
部分,嵌套在LEVELING_UP
部分内部,我们添加了以下代码:
-
将
numZombies
初始化为10
。随着项目的进展,这最终将变为动态的,并基于当前波次。 -
删除任何先前分配的内存。否则,每次调用
createHorde
都会占用越来越多的内存,但不会释放先前僵尸群的内存。 -
然后,我们调用
createHorde
函数并将返回的内存地址赋值给zombies
。 -
我们还用
numZombies
初始化zombiesAlive
,因为我们此时还没有杀死任何僵尸。
添加以下高亮代码,这是我们刚刚讨论过的:
if (state == State::PLAYING)
{
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
现在,将以下高亮代码添加到ZombieArena.cpp
文件中:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
// Make a note of the players new position
Vector2f playerPosition(player.getCenter());
// Make the view centre around the player
mainView.setCenter(player.getCenter());
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
}// End updating the scene
所有新的前置代码只是遍历僵尸数组,检查当前僵尸是否存活,如果是,就使用必要的参数调用它的update
函数。
添加以下代码来绘制所有僵尸:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
// Draw the player
window.draw(player.getSprite());
}
之前的代码遍历所有僵尸,并调用 getSprite
函数,以便 draw
函数执行其工作。我们不检查僵尸是否存活,因为即使僵尸已经死亡,我们也希望绘制血液飞溅。
在主函数的末尾,我们需要确保删除我们的指针,因为这不仅是良好的实践,而且在很多情况下也是必要的。然而,从技术上讲,这并不是必要的,因为游戏即将退出,操作系统将在 return 0
语句之后回收所有使用的内存:
}// End of main game loop
// Delete the previously allocated memory (if it exists)
delete[] zombies;
return 0;
}
您可以运行游戏,并看到僵尸在竞技场边缘孵化。它们将以各自的速度立即直奔玩家。为了好玩,我增加了竞技场的大小,并将僵尸的数量增加到 1,000,如下面的截图所示:
这将导致不良后果!
注意,由于我们在 第八章 中编写的代码,您也可以使用 Enter 键暂停和恢复群魔的进攻。
让我们修复一些类仍然直接使用 Texture
实例并修改为使用新的 TextureHolder
类的事实。
使用 TextureHolder
类管理所有纹理
由于我们有 TextureHolder
类,我们不妨保持一致,并使用它来加载所有我们的纹理。让我们对现有代码进行一些非常小的修改,这些代码用于加载背景精灵图和玩家的纹理。
改变背景获取纹理的方式
在 ZombieArena.cpp
文件中,找到以下代码:
// Load the texture for our background vertex array
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
删除之前高亮的代码,并用以下高亮的代码替换它,该代码使用了我们新的 TextureHolder
类:
// Load the texture for our background vertex array
Texture textureBackground = TextureHolder::GetTexture(
"graphics/background_sheet.png");
让我们更新 Player
类获取纹理的方式。
改变玩家获取纹理的方式
在 Player.cpp
文件中,在构造函数内,找到以下代码:
#include "player.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
// Set the origin of the sprite to the centre,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
删除之前高亮的代码,并用以下高亮的代码替换它,该代码使用了我们新的 TextureHolder
类。此外,添加 include
指令将 TextureHolder
头文件添加到文件中。新的代码如下所示高亮,并处于上下文中:
#include "player.h"
#include "TextureHolder.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Associate a texture with the sprite
// !!Watch this space!!
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/player.png"));
// Set the origin of the sprite to the centre,
// for smooth rotation
m_Sprite.setOrigin(25, 25);
}
小贴士
从现在起,我们将使用 TextureHolder
类来加载所有纹理。
摘要
在本章中,我们介绍了指针,并讨论了它们是持有特定类型对象内存地址的变量。随着本书的进展和指针力量的揭示,其全部意义将开始显现。我们还使用了指针来创建一大群僵尸,可以使用指针访问,这实际上也是数组第一个元素相同的东西。
我们学习了 STL,特别是 map
类。我们实现了一个类,用于存储所有我们的纹理,并提供对它们的访问。
你可能已经注意到僵尸看起来并不危险。它们只是穿过玩家而不会留下任何痕迹。目前,这是好事,因为玩家没有防御自己的方法。
在下一章中,我们将创建两个更多的类:一个用于弹药和健康拾取,另一个用于玩家可以射击的子弹。在完成这些之后,我们将学习如何检测碰撞,以便子弹和僵尸造成伤害,玩家可以收集拾取物。
FAQ
这里有一些可能出现在你脑海中的问题:
Q) 指针和引用有什么区别?
A) 指针就像带有助推器的引用。指针可以被改变以指向不同的变量(内存地址),以及指向自由存储上的动态分配内存。
Q) 数组和指针之间有什么关系?
A) 数组实际上是它们第一个元素的常量指针。
Q) 你能提醒我一下new
关键字和内存泄漏吗?
A) 当我们使用new
关键字在自由存储上分配内存时,即使创建它的函数已经返回并且所有局部变量都已消失,内存仍然存在。当我们完成在自由存储上使用内存后,我们必须释放它。因此,如果我们想在函数的生命周期之外持久化使用自由存储上的内存,我们必须确保保留对其的指针,否则我们将会有内存泄漏。这就像把所有的东西都放在我们的房子里,然后忘记我们住在哪里!当我们从createHorde
返回zombies
数组时,就像从createHorde
传递接力棒(内存地址)到main
。这就像说:“好吧,这是你的僵尸群——现在它们是你的责任了。”而且,我们不想有任何泄漏的僵尸在我们的 RAM 中四处游荡!所以,我们必须记得调用delete
来删除动态分配内存的指针。
第十二章:第十一章: 碰撞检测、拾取物和子弹
到目前为止,我们已经实现了游戏的主要视觉方面。我们有一个可控的角色在满是僵尸的竞技场中奔跑,僵尸会追逐他们。问题是它们之间没有互动。一个僵尸可以毫无阻碍地穿过玩家而不会留下任何痕迹。我们需要检测僵尸和玩家之间的碰撞。
如果僵尸能够伤害玩家并最终将其杀死,那么给玩家一些子弹来应对枪支是公平的。然后我们需要确保子弹能够击中并杀死僵尸。
同时,如果我们正在编写子弹、僵尸和玩家的碰撞检测代码,那么添加一个用于健康和弹药拾取物的类也是一个好时机。
在本章中,我们将执行以下操作,并按以下顺序介绍内容:
-
射击子弹
-
添加准星并隐藏鼠标指针
-
生成拾取物
-
检测碰撞
让我们从Bullet
类开始。
编写子弹类
我们将使用 SFML 的RectangleShape
类来在视觉上表示子弹。我们将编写一个具有RectangleShape
成员以及其他成员数据和函数的Bullet
类。然后,我们将按照以下步骤将子弹添加到游戏中:
-
首先,我们将编写
Bullet.h
文件。这将揭示成员数据的所有细节和函数的原型。 -
接下来,我们将编写
Bullet.cpp
文件,它当然将包含Bullet
类的所有函数的定义。随着我们的逐步介绍,我将解释Bullet
类型的对象将如何工作以及如何控制。 -
最后,我们将在
main
函数中声明一个子弹数组。我们还将实现射击控制方案、管理玩家的剩余弹药和重新装弹。
让我们从第一步开始。
编写子弹头文件
要创建新的头文件,右键单击Bullet.h
。
将以下私有成员变量,以及Bullet
类声明,添加到Bullet.h
文件中。然后我们可以逐一解释它们的作用:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bullet
{
private:
// Where is the bullet?
Vector2f m_Position;
// What each bullet looks like
RectangleShape m_BulletShape;
// Is this bullet currently whizzing through the air
bool m_InFlight = false;
// How fast does a bullet travel?
float m_BulletSpeed = 1000;
// What fraction of 1 pixel does the bullet travel,
// Horizontally and vertically each frame?
// These values will be derived from m_BulletSpeed
float m_BulletDistanceX;
float m_BulletDistanceY;
// Some boundaries so the bullet doesn't fly forever
float m_MaxX;
float m_MinX;
float m_MaxY;
float m_MinY;
// Public function prototypes go here
};
在之前的代码中,第一个成员是一个名为m_Position
的Vector2f
,它将保存子弹在游戏世界中的位置。
接下来,我们声明一个名为m_BulletShape
的RectangleShape
,因为我们为每个子弹使用了一个简单的非纹理图形,就像我们在 Timber 中为时间条所做的那样!!!。
代码随后声明了一个Boolean
类型的m_InFlight
,它将跟踪子弹是否正在空中飞驰。这将允许我们决定是否需要在每一帧调用其update
函数,以及是否需要运行碰撞检测检查。
float
类型的变量m_BulletSpeed
将(你可能可以猜到)保存子弹将以每秒多少像素的速度移动。它被初始化为1000
,这是一个有点任意的选择,但效果不错。
接下来,我们还有两个更多的 float
变量,m_BulletDistanceX
和 m_BulletDistanceY
。由于移动子弹的计算比移动僵尸或玩家要复杂一些,我们将从这两个变量中受益,我们将对它们进行计算。它们将用于决定子弹在每个帧中的水平和垂直位置变化。
最后,我们还有四个 float
变量(m_MaxX
、m_MinX
、m_MaxY
和 m_MinY
),稍后它们将被初始化以保存子弹的最大和最小水平以及垂直位置。
很可能,这些变量中的一些需求并不立即明显,但当我们看到它们在 Bullet.cpp
文件中的实际应用时,一切将变得清晰起来。
现在,将所有公共函数原型添加到 Bullet.h
文件中:
// Public function prototypes go here
public:
// The constructor
Bullet();
// Stop the bullet
void stop();
// Returns the value of m_InFlight
bool isInFlight();
// Launch a new bullet
void shoot(float startX, float startY,
float xTarget, float yTarget);
// Tell the calling code where the bullet is in the world
FloatRect getPosition();
// Return the actual shape (for drawing)
RectangleShape getShape();
// Update the bullet each frame
void update(float elapsedTime);
};
让我们依次运行每个函数,然后我们可以继续编写它们的定义。
首先,我们有 Bullet
函数,当然是构造函数。在这个函数中,我们将设置每个 Bullet
实例,使其准备就绪。
当子弹已经执行但需要停止时,将调用 stop
函数。
isInFlight
函数返回一个布尔值,将用于测试子弹是否当前正在飞行。
shoot
函数的功能从其名称中可以看出,但其工作原理值得讨论。现在,只需注意它有四个 float
参数将被传入。这四个值代表子弹的起始(玩家所在位置)水平和垂直位置,以及垂直和水平目标位置(准星所在位置)。
getPosition
函数返回一个表示子弹位置的 FloatRect
。此函数将用于检测与僵尸的碰撞。你可能还记得在 第十章,指针、标准模板库和纹理管理 中,僵尸也有一个 getPosition
函数。
接下来,我们有 getShape
函数,它返回一个 RectangleShape
类型的对象。正如我们讨论过的,每个子弹都通过一个 RectangleShape
对象来表示。因此,getShape
函数将用于获取 RectangleShape
当前状态的副本以便绘制。
最后,并且希望如预期的那样,是 update
函数,它有一个 float
参数,表示自上次调用 update
以来经过的秒数的一部分。update
方法将改变子弹在每个帧中的位置。
让我们查看并编写函数定义。
编写 Bullet 源文件
现在,我们可以创建一个新的 .cpp
文件,该文件将包含函数定义。右键点击 Bullet.cpp
。最后,点击 添加 按钮。我们现在可以开始编写类的代码了。
添加以下代码,这是用于包含指令和构造函数的。我们知道它是一个构造函数,因为函数的名称与类相同:
#include "bullet.h"
// The constructor
Bullet::Bullet()
{
m_BulletShape.setSize(sf::Vector2f(2, 2));
}
Bullet
构造函数需要做的唯一事情是设置 m_BulletShape
的大小,这是一个 RectangleShape
对象。代码将大小设置为两个像素乘以两个像素。
接下来,我们将编写更复杂的 shoot
函数。将以下代码添加到 Bullet.cpp
文件中,并研究它,然后我们可以讨论它:
void Bullet::shoot(float startX, float startY,
float targetX, float targetY)
{
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than 1 needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
// Point the bullet in the right direction
if (targetX < startX)
{
m_BulletDistanceX *= -1;
}
if (targetY < startY)
{
m_BulletDistanceY *= -1;
}
// Set a max range of 1000 pixels
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
}
为了揭开 shoot
函数的神秘面纱,我们将将其拆分,并分块讨论我们刚刚添加的代码。
首先,让我们回顾一下签名。shoot
函数接收子弹的起始和目标水平及垂直位置。调用代码将根据玩家精灵的位置和准星的位置提供这些信息。这里再次说明:
void Bullet::shoot(float startX, float startY,
float targetX, float targetY)
在 shoot
函数内部,我们将 m_InFlight
设置为 true
,并使用 startX
和 startY
参数定位子弹。这是那段代码的再次说明:
// Keep track of the bullet
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
现在,我们使用一点三角学来确定子弹的移动斜率。子弹在水平和垂直方向上的进度必须根据从子弹的起始和目标之间绘制的线的斜率而变化。变化率不能相同或非常陡峭,否则斜射会先到达水平位置,然后是垂直位置,反之亦然。
下面的代码根据直线的方程推导出斜率。然后,它检查斜率是否小于零,如果是,则将其乘以 -1。这是因为传入的起始和目标坐标可以是负数或正数,而我们总是希望每一帧的进度量是正数。乘以 -1 简单地将负数转换为它的正数等价物,因为负数乘以负数得到正数。实际的运动方向将在 update
函数中通过添加或减去在这个函数中得到的正数值来处理。
接下来,我们通过将子弹的速度 (m_BulletSpeed
) 除以 1 加上斜率来计算水平到垂直距离的比率。这将允许我们根据子弹所朝向的目标,在每一帧正确地改变子弹的水平位置和垂直位置。
最后,在这段代码中,我们将值分配给 m_BulletDistanceY
和 m_BulletDistanceX
:
// Calculate the gradient of the flight path
float gradient = (startX - targetX) / (startY - targetY);
// Any gradient less than zero needs to be negative
if (gradient < 0)
{
gradient *= -1;
}
// Calculate the ratio between x and y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Set the "speed" horizontally and vertically
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
以下代码更加直接。我们只是设置子弹可以达到的最大水平和垂直位置。我们不想让子弹永远飞行。在更新函数中,我们将看到子弹是否已经通过了其最大或最小位置:
// Set a max range of 1000 pixels in any direction
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
以下代码将代表子弹的精灵移动到其起始位置。我们使用 Sprite
的 setPosition
函数,就像我们之前经常做的那样:
// Position the bullet ready to be drawn
m_BulletShape.setPosition(m_Position);
接下来,我们有四个简单的函数。让我们添加 stop
、isInFlight
、getPosition
和 getShape
函数:
void Bullet::stop()
{
m_InFlight = false;
}
bool Bullet::isInFlight()
{
return m_InFlight;
}
FloatRect Bullet::getPosition()
{
return m_BulletShape.getGlobalBounds();
}
RectangleShape Bullet::getShape()
{
return m_BulletShape;
}
stop
函数只是将 m_InFlight
变量设置为 false
。isInFlight
函数返回该变量当前值。因此,我们可以看到 shoot
使子弹开始移动,stop
使其停止,而 isInFlight
通知我们当前状态。
getPosition
函数返回一个 FloatRect
。我们很快就会看到如何使用每个游戏对象的 FloatRect
来检测碰撞。
最后,对于之前的代码,getShape
返回一个 RectangleShape
,这样我们就可以在每一帧中绘制子弹。
在我们可以开始使用 Bullet
对象之前,我们需要实现最后一个函数 update
。添加以下代码,研究它,然后我们可以讨论它:
void Bullet::update(float elapsedTime)
{
// Update the bullet position variables
m_Position.x += m_BulletDistanceX * elapsedTime;
m_Position.y += m_BulletDistanceY * elapsedTime;
// Move the bullet
m_BulletShape.setPosition(m_Position);
// Has the bullet gone out of range?
if (m_Position.x < m_MinX || m_Position.x > m_MaxX ||
m_Position.y < m_MinY || m_Position.y > m_MaxY)
{
m_InFlight = false;
}
}
在 update
函数中,我们使用 m_BulletDistanceX
和 m_BulletDistanceY
,乘以上一帧以来的时间来移动子弹。记住,这两个变量的值是在 shoot
函数中计算的,代表移动子弹所需的角度的梯度(彼此之间的比率)。然后,我们使用 setPosition
函数实际移动 RectangleShape
。
在 update
函数中我们做的最后一件事是测试子弹是否已经移动到其最大射程之外。稍微复杂的 if
语句检查 m_Position.x
和 m_Position.y
是否与在 shoot
函数中计算出的最大和最小值相符。这些最大和最小值存储在 m_MinX
、m_MaxX
、m_MinY
和 m_MaxY
中。如果测试结果为真,则将 m_InFlight
设置为 false
。
Bullet
类已完成。现在,我们将看看如何在 main
函数中射击一些子弹。
使子弹飞行
我们将通过以下六个步骤使子弹可用:
-
为
Bullet
类添加必要的包含指令。 -
添加一些控制变量和一个数组来存储一些
Bullet
实例。 -
处理玩家按下 R 键来重新装填。
-
处理玩家按下左鼠标按钮来发射子弹。
-
在每一帧中更新所有正在飞行的子弹。
-
在每一帧中绘制正在飞行的子弹。
包含 Bullet 类
添加包含指令以使 Bullet 类可用:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
using namespace sf;
让我们继续下一步。
控制变量和子弹数组
这里有一些变量用于跟踪弹夹大小、备用子弹、子弹、弹夹中剩余的子弹、当前射速(从每秒一发开始)以及上次发射子弹的时间。
添加以下突出显示的代码。然后,我们可以继续前进,并看到本节其余部分的所有这些变量在行动中的表现:
// Prepare for a horde of zombies
int numZombies;
int numZombiesAlive;
Zombie* zombies = NULL;
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// The main game loop
while (window.isOpen())
接下来,让我们处理玩家按下 R 键时发生的情况,该键用于重新装填弹夹。
重新装填枪支
现在,我们将处理与射击子弹相关的玩家输入。首先,我们将处理按下 R 键来重新装填枪支。我们将使用 SFML 事件来完成此操作。
添加以下高亮显示的代码。它展示了大量的上下文,以确保代码放在正确的位置。研究完代码后,我们再讨论它:
// Handle events
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Pause a game while playing
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Restart while paused
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Reset the clock so there isn't a frame jump
clock.restart();
}
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
// Reloading
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Plenty of bullets. Reload.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
}
else if (bulletsSpare > 0)
{
// Only few bullets left
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
}
else
{
// More here soon?!
}
}
}
}
}// End event polling
之前的代码嵌套在游戏循环的事件处理部分(while(window.pollEvent)
)中,在仅当游戏实际正在播放时执行的代码块内(if(state == State::Playing)
)。很明显,我们不希望玩家在游戏结束时或暂停时重新装弹,所以我们按照描述的方式包裹新代码,以此实现这一目的。
在新代码本身中,我们首先使用 if (event.key.code == Keyboard::R)
测试是否按下了 R 键。一旦我们检测到按下了 R 键,剩余的代码就会被执行。以下是 if
、else if
和 else
块的结构:
if(bulletsSpare >= clipSize)
...
else if(bulletsSpare > 0)
...
else
...
之前的结构允许我们处理三种可能的场景,如下所示:
-
玩家按下了
R
键,并且他们还有比弹夹能装的更多的备用子弹。在这种情况下,弹夹被重新装填,备用子弹的数量减少。 -
玩家有一些备用子弹,但不足以完全填满弹夹。在这种情况下,弹夹被填满至玩家拥有的备用子弹数量,备用子弹的数量被设置为零。
-
玩家按下了 R 键,但他们完全没有备用子弹。对于这种情况,我们实际上不需要改变变量。然而,当我们在 第十三章 中实现声音效果时,声音效果、文件输入/输出和完成游戏,我们将在那里播放一个声音效果,所以我们将保留空的
else
块以备后用。
现在,让我们射击一颗子弹。
射击子弹
在这里,我们将处理左鼠标按钮被点击以发射子弹的情况。添加以下高亮显示的代码并仔细研究它:
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
// Fire a bullet
if (Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Pass the centre of the player
// and the centre of the cross-hair
// to the shoot function
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
bulletsInClip--;
}
}// End fire a bullet
}// End WASD while playing
所有之前的代码都被包裹在一个 if
语句中,该语句在左鼠标按钮被按下时执行,即 if (Mouse::isButtonPressed(sf::Mouse::Left))
。请注意,即使玩家只是按住按钮,代码也会重复执行。我们现在要讲解的代码控制了射击速率。
在前面的代码中,我们检查游戏已过去的时间(gameTimeTotal
)减去玩家上次射击子弹的时间(lastPressed
)是否大于 1,000,除以当前射击速率,并且玩家至少有一个子弹在弹夹中。我们使用 1,000 因为这是秒中的毫秒数。
如果这个测试成功,将执行实际发射子弹的代码。射击子弹很简单,因为我们已经在 Bullet
类中完成了所有艰苦的工作。我们只需在 bullets
数组中的当前子弹上调用 shoot
。我们传入玩家和准星当前的水平位置和垂直位置。子弹将通过 Bullet
类的 shoot
函数中的代码进行配置并设置为飞行状态。
我们必须做的只是跟踪子弹数组。我们递增了currentBullet
变量。然后,我们需要检查是否用if (currentBullet > 99)
语句发射了最后一个子弹(99 号)。如果是最后一个子弹,我们将currentBullet
设置为零。如果不是最后一个子弹,那么在射击速率允许且玩家按下左鼠标按钮时,下一发子弹就可以发射。
最后,在先前的代码中,我们将子弹发射的时间存储到lastPressed
中,并将bulletsInClip
递减。
现在,我们可以每帧更新每个子弹。
每帧更新子弹
添加以下高亮代码以遍历子弹数组,检查子弹是否在飞行中,如果是,则调用其更新函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
}// End updating the scene
最后,我们将绘制所有子弹。
每帧绘制子弹
添加以下高亮代码以遍历bullets
数组,检查子弹是否在飞行中,如果是,则绘制它:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Draw the player
window.draw(player.getSprite());
}
运行游戏以测试子弹。注意,在你需要按R键重新装填之前,你可以发射六发子弹。显然缺少的是一些视觉指示器,显示弹夹中的子弹数量和备用子弹数量。另一个问题是玩家可能会非常快地用完子弹,尤其是由于子弹完全没有停止力,它们会直接穿过僵尸。再加上玩家被期望瞄准鼠标指针而不是精确的准星,很明显我们还有很多工作要做。
在下一章中,我们将通过 HUD 提供视觉反馈。我们将用准星替换鼠标光标,然后在那之后生成一些拾取物来补充子弹和生命值。最后,在这一章中,我们将处理碰撞检测,使子弹和僵尸造成伤害,并使玩家能够真正地拾取物品。
给玩家一个准星
添加准星很简单,只需要一个新概念。添加以下高亮代码,然后我们可以运行它:
// 100 bullets should do
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
// When was the fire button last pressed?
Time lastPressed;
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// The main game loop
while (window.isOpen())
首先,我们在window
对象上调用setMouseCursorVisible
函数。然后我们加载一个Texture
并声明一个Sprite
实例,并以通常的方式初始化它。此外,我们将精灵的原点设置为它的中心,使其更方便,也更简单地将子弹飞向中间,正如你所期望的那样。
现在,我们需要每帧更新准星,使用鼠标的全球坐标。添加以下高亮代码行,它使用mouseWorldPosition
向量设置准星的每帧位置:
/*
****************
UPDATE THE FRAME
****************
*/
if (state == State::PLAYING)
{
// Update the delta time
Time dt = clock.restart();
// Update the total game time
gameTimeTotal += dt;
// Make a decimal fraction of 1 from the delta time
float dtAsSeconds = dt.asSeconds();
// Where is the mouse pointer
mouseScreenPosition = Mouse::getPosition();
// Convert mouse position to world coordinates of mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Set the crosshair to the mouse world location
spriteCrosshair.setPosition(mouseWorldPosition);
// Update the player
player.update(dtAsSeconds, Mouse::getPosition());
接下来,正如你可能已经预料到的,我们可以在每一帧绘制准星。在所示位置添加以下高亮代码行。这一行代码无需解释,但它在所有其他游戏对象之后的定位很重要,因此它被绘制在最上面:
/*
**************
Draw the scene
**************
*/
if (state == State::PLAYING)
{
window.clear();
// set the mainView to be displayed in the window
// And draw everything related to it
window.setView(mainView);
// Draw the background
window.draw(background, &textureBackground);
// Draw the zombies
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Draw the player
window.draw(player.getSprite());
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在,你可以运行游戏,将看到一个酷炫的准星而不是鼠标光标:
注意子弹是如何干净利落地穿过准星中心的。射击机制的工作方式类似于允许玩家选择从腰间射击还是瞄准射击。如果玩家将准星保持在中心附近,他们可以快速射击和转身,但必须仔细判断远处僵尸的位置。
或者,玩家可以将准星直接悬停在远处僵尸的头上,从而获得精确命中;然而,如果僵尸从另一个方向攻击,他们需要将准星移动得更远。
游戏的一个有趣改进是向每次射击添加一小部分随机不准确度。这种不准确度可能可以通过波次之间的升级来减轻。
编写拾取类
在本节中,我们将编写一个具有Sprite
成员以及其他成员数据和函数的Pickup
类。我们将在几个步骤中将拾取物添加到游戏中:
-
首先,我们将编写
Pickup.h
文件。这将揭示成员数据和函数原型的所有细节。 -
然后,我们将编写
Pickup.cpp
文件,当然,它将包含Pickup
类所有函数的定义。随着我们逐步分析,我将详细解释Pickup
类型对象的工作方式和控制方式。 -
最后,我们将在
main
函数中使用Pickup
类来生成、更新和绘制它们。
让我们从第一步开始。
编写拾取头文件
要创建新的头文件,右键点击Pickup.h
。
将以下代码添加并研究到Pickup.h
文件中,然后我们可以逐一分析:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Pickup
{
private:
//Start value for health pickups
const int HEALTH_START_VALUE = 50;
const int AMMO_START_VALUE = 12;
const int START_WAIT_TIME = 10;
const int START_SECONDS_TO_LIVE = 5;
// The sprite that represents this pickup
Sprite m_Sprite;
// The arena it exists in
IntRect m_Arena;
// How much is this pickup worth?
int m_Value;
// What type of pickup is this?
// 1 = health, 2 = ammo
int m_Type;
// Handle spawning and disappearing
bool m_Spawned;
float m_SecondsSinceSpawn;
float m_SecondsSinceDeSpawn;
float m_SecondsToLive;
float m_SecondsToWait;
// Public prototypes go here
};
之前的代码声明了Pickup
类的所有私有变量。尽管名称应该相当直观,但可能并不明显为什么需要其中许多变量。让我们从顶部开始逐一了解:
-
const int HEALTH_START_VALUE = 50
:这个常量变量用于设置所有健康拾取物的起始值。该值将用于初始化m_Value
变量,该变量需要在游戏过程中进行操作。 -
const int AMMO_START_VALUE = 12
:这个常量变量用于设置所有弹药拾取物的起始值。该值将用于初始化m_Value
变量,该变量需要在游戏过程中进行操作。 -
const int START_WAIT_TIME = 10
:这个变量决定了拾取物消失后重生前的等待时间。它将用于初始化m_SecondsToWait
变量,该变量可以在游戏过程中进行操作。 -
const int START_SECONDS_TO_LIVE = 5
:这个变量决定了拾取物在生成和消失之间的持续时间。像前三个常量一样,它有一个与之关联的非常量,可以在游戏过程中进行操作。它用来初始化的是m_SecondsToLive
。 -
Sprite m_Sprite
:这是用于视觉表示对象的精灵。 -
IntRect m_Arena
:这将保存当前竞技场的尺寸,以帮助拾取在合理的位置生成。 -
int m_Value
:这个拾取值代表多少生命值或弹药?当玩家升级生命值或弹药拾取的值时,将使用此值。 -
int m_Type
:这将分别为生命值或弹药是 1 或 2。我们本可以使用枚举类,但似乎对于只有两个选项来说有点过度了。 -
bool m_Spawned
:拾取当前是否已生成? -
float m_SecondsSinceSpawn
:拾取被生成以来有多长时间了? -
float m_SecondsSinceDeSpawn
:拾取被销毁(消失)以来有多长时间了? -
float m_SecondsToLive
:这个拾取在销毁前应该保持生成多久? -
float m_SecondsToWait
:这个拾取在重新生成前应该保持销毁多久?小贴士
注意,这个类的复杂性的大部分是由于变量生成时间和其可升级的特性。如果拾取在收集后重新生成并且具有固定值,这将是一个非常简单的类。我们需要我们的拾取是可升级的,这样玩家就必须制定策略才能通过波次。
接下来,将以下公共函数原型添加到Pickup.h
文件中。请确保您熟悉新代码,这样我们才能一起审查:
// Public prototypes go here
public:
Pickup::Pickup(int type);
// Prepare a new pickup
void setArena(IntRect arena);
void spawn();
// Check the position of a pickup
FloatRect getPosition();
// Get the sprite for drawing
Sprite getSprite();
// Let the pickup update itself each frame
void update(float elapsedTime);
// Is this pickup currently spawned?
bool isSpawned();
// Get the goodness from the pickup
int gotIt();
// Upgrade the value of each pickup
void upgrade();
};
让我们简要地讨论一下每个函数定义。
-
第一个函数是构造函数,其名称与类名相同。请注意,它接受一个单个的
int
参数。这将用于初始化它将是什么类型的拾取(生命值或弹药)。 -
setArena
函数接收一个IntRect
。这个函数将在每一波的开始时为每个Pickup
实例调用。然后,Pickup
对象将“知道”它们可以生成的区域。 -
当然,
spawn
函数将处理拾取的生成。 -
getPosition
函数,就像在Player
、Zombie
和Bullet
类中一样,将返回一个FloatRect
实例,表示对象在游戏世界中的当前位置。 -
getSprite
函数返回一个Sprite
对象,允许拾取在每一帧被绘制。 -
update
函数接收上一帧所花费的时间。它使用这个值来更新其私有变量,并做出何时生成和销毁的决定。 -
isSpawned
函数返回一个布尔值,这将让调用代码知道拾取是否当前已生成。 -
当检测到与玩家的碰撞时,将调用
gotIt
函数。然后,Pickup
类的代码可以准备在适当的时间重新生成。请注意,它返回一个int
值,以便调用代码知道拾取在生命值或弹药中的“价值”。 -
当玩家在游戏的升级阶段选择升级拾取属性时,将调用
upgrade
函数。
现在我们已经了解了成员变量和函数原型,在编写函数定义时应该相当容易跟随。
编写 Pickup 类函数定义
现在,我们可以创建一个新的 .cpp
文件,该文件将包含函数定义。右键单击 Pickup.cpp
。最后,点击 添加 按钮。我们现在准备好编写类代码。
将以下代码添加到 Pickup.cpp
文件中。请务必审查代码,以便我们可以讨论它:
#include "Pickup.h"
#include "TextureHolder.h"
Pickup::Pickup(int type)
{
// Store the type of this pickup
m_Type = type;
// Associate the texture with the sprite
if (m_Type == 1)
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/health_pickup.png"));
// How much is pickup worth
m_Value = HEALTH_START_VALUE;
}
else
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/ammo_pickup.png"));
// How much is pickup worth
m_Value = AMMO_START_VALUE;
}
m_Sprite.setOrigin(25, 25);
m_SecondsToLive = START_SECONDS_TO_LIVE;
m_SecondsToWait = START_WAIT_TIME;
}
在之前的代码中,我们添加了熟悉的包含指令。然后,我们添加了 Pickup
构造函数。我们知道它是构造函数,因为它与类的名称相同。
构造函数接收一个名为 type
的 int
,代码首先将 type
接收的值分配给 m_Type
。之后,有一个 if else
块检查 m_Type
是否等于 1。如果是,则将 m_Sprite
与健康拾取纹理关联,并将 m_Value
设置为 HEALTH_START_VALUE
。
如果 m_Type
不等于 1,则 else
块将弹药拾取纹理与 m_Sprite
关联,并将 AMMO_START_VALUE
的值分配给 m_Value
。
在 if
else
块之后,代码使用 setOrigin
函数将 m_Sprite
的原点设置为中心,并将 START_SECONDS_TO_LIVE
和 START_WAIT_TIME
分别分配给 m_SecondsToLive
和 m_SecondsToWait
。
构造函数已成功准备了一个 Pickup
对象,该对象已准备好使用。
现在,我们将添加 setArena
函数。在添加时检查代码:
void Pickup::setArena(IntRect arena)
{
// Copy the details of the arena to the pickup's m_Arena
m_Arena.left = arena.left + 50;
m_Arena.width = arena.width - 50;
m_Arena.top = arena.top + 50;
m_Arena.height = arena.height - 50;
spawn();
}
我们刚刚编写的 setArena
函数简单地复制了传入的 arena
对象中的值,但在左侧和顶部增加了 + 50
,在右侧和底部减少了 - 50
。现在 Pickup
对象已经知道它可以生成的区域。然后 setArena
函数调用它自己的 spawn
函数,以便为每一帧的绘制和更新做好最后的准备。
接下来是 spawn
函数。在 setArena
函数之后添加以下代码:
void Pickup::spawn()
{
// Spawn at a random location
srand((int)time(0) / m_Type);
int x = (rand() % m_Arena.width);
srand((int)time(0) * m_Type);
int y = (rand() % m_Arena.height);
m_SecondsSinceSpawn = 0;
m_Spawned = true;
m_Sprite.setPosition(x, y);
}
spawn
函数执行了准备拾取所需的所有必要操作。首先,它初始化随机数生成器,并为对象的水平和垂直位置获取一个随机数。请注意,它使用 m_Arena.width
和 m_Arena.height
变量作为可能水平和垂直位置的范围。
m_SecondsSinceSpawn
变量被设置为零,以便在它被销毁之前允许的时间长度重置。m_Spawned
变量被设置为 true
,这样当我们从 main
中调用 isSpawned
时,我们将得到一个积极的响应。最后,使用 setPosition
将 m_Sprite
移动到位置,准备绘制到屏幕上。
在下面的代码块中,我们有三个简单的获取函数。getPosition
函数返回 m_Sprite
当前位置的 FloatRect
,getSprite
返回 m_Sprite
本身的副本,而 isSpawned
根据对象当前是否生成返回 true
或 false
。
添加并检查我们刚刚讨论的代码:
FloatRect Pickup::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Pickup::getSprite()
{
return m_Sprite;
}
bool Pickup::isSpawned()
{
return m_Spawned;
}
接下来,我们将编写 gotIt
函数。当玩家触摸/碰撞(获取)拾取物品时,将从 main
中调用此函数。在 isSpawned
函数之后添加 gotIt
函数:
int Pickup::gotIt()
{
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
return m_Value;
}
gotIt
函数将 m_Spawned
设置为 false
,这样我们就知道不再绘制和检查碰撞。m_SecondsSinceDespawn
被设置为零,以便从零开始重新开始生成倒计时。然后,m_Value
返回到调用代码,以便调用代码可以处理添加额外的弹药或健康,根据情况而定。
接下来,我们需要编写 update
函数,该函数将我们迄今为止看到的许多变量和函数结合起来。添加并熟悉 update
函数,然后我们可以讨论它:
void Pickup::update(float elapsedTime)
{
if (m_Spawned)
{
m_SecondsSinceSpawn += elapsedTime;
}
else
{
m_SecondsSinceDeSpawn += elapsedTime;
}
// Do we need to hide a pickup?
if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
{
// Remove the pickup and put it somewhere else
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
}
// Do we need to spawn a pickup
if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
{
// spawn the pickup and reset the timer
spawn();
}
}
update
函数被分为四个块,每个块在每个帧都会考虑执行:
-
如果
m_Spawned
为真,则执行if
块:if (m_Spawned)
. 这段代码将当前帧的时间添加到m_SecondsSinceSpawned
中,该变量用于跟踪拾取物品被生成的时间。 -
如果
m_Spawned
为假,则执行相应的else
块。此块将当前帧所花费的时间添加到m_SecondsSinceDeSpawn
中,该变量用于跟踪拾取物品上次被销毁(隐藏)后等待了多长时间。 -
另一个
if
块在拾取物品生成时间超过预期时执行:if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
。此块将m_Spawned
设置为false
并将m_SecondsSinceDeSpawn
重置为零。现在,块 2 将执行,直到再次生成它。 -
当从生成到销毁的等待时间超过必要的等待时间,并且拾取物品当前未生成时,执行一个最终的
if
块:if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
. 当此块执行时,是时候再次生成拾取物品了,并调用spawn
函数。
这四个测试控制着拾取物品的隐藏和显示。
最后,添加 upgrade
函数的定义:
void Pickup::upgrade()
{
if (m_Type == 1)
{
m_Value += (HEALTH_START_VALUE * .5);
}
else
{
m_Value += (AMMO_START_VALUE * .5);
}
// Make them more frequent and last longer
m_SecondsToLive += (START_SECONDS_TO_LIVE / 10);
m_SecondsToWait -= (START_WAIT_TIME / 10);
}
upgrade
函数检查拾取物品的类型,无论是健康还是弹药,然后将(适当的)起始值的 50% 添加到 m_Value
。在 if
else
块之后的下两行增加了拾取物品将保持生成的时长,并减少了玩家在生成之间必须等待的时间。
当玩家在 LEVELING_UP
状态下选择提升拾取物品时,会调用此函数。
我们的 Pickup
类已准备好使用。
使用拾取类
在实现 Pickup
类的所有艰苦工作之后,我们现在可以继续在游戏引擎中编写代码,将一些拾取物放入游戏中。
我们首先需要在 ZombieArena.cpp
文件中添加一个包含指令:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
在以下代码中,我们添加了两个 Pickup
实例:一个称为 healthPickup
,另一个称为 ammoPickup
。我们分别将值 1 和 2 传递给构造函数,以便它们初始化为正确的拾取物类型。添加我们刚刚讨论的突出显示的代码:
// Hide the mouse pointer and replace it with crosshair
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture(
"graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// The main game loop
while (window.isOpen())
在键盘处理的 LEVELING_UP
状态中,在嵌套的 PLAYING
代码块内添加以下突出显示的行:
if (state == State::PLAYING)
{
// Prepare the level
// We will modify the next two lines later
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Configure the pick-ups
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Create a horde of zombies
numZombies = 10;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Reset the clock so there isn't a frame jump
clock.restart();
}
上述代码只是将 arena
传递给每个拾取物的 setArena
函数。现在拾取物知道它们可以在哪里生成。此代码在每一波新出现时执行,因此,随着竞技场大小的增长,Pickup
对象将得到更新。
以下代码简单地调用每个帧上每个 Pickup
对象的 update
函数:
// Loop through each Zombie and update them
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Update any bullets that are in-flight
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
}// End updating the scene
以下代码在游戏循环的绘制部分检查拾取物是否当前已生成,如果是,则绘制它。让我们添加它:
// Draw the player
window.draw(player.getSprite());
// Draw the pick-ups, if currently spawned
if (ammoPickup.isSpawned())
{
window.draw(ammoPickup.getSprite());
}
if (healthPickup.isSpawned())
{
window.draw(healthPickup.getSprite());
}
//Draw the crosshair
window.draw(spriteCrosshair);
}
现在,你可以运行游戏并看到拾取物生成和消失。然而,你实际上还不能捡起它们:
现在我们已经拥有了游戏中的所有对象,现在是时候让它们相互交互(碰撞)了。
检测碰撞
我们只需要知道游戏中的某些对象何时触摸到其他对象。然后我们可以适当地响应该事件。在我们的类中,我们已经添加了当我们的对象碰撞时将被调用的函数。它们如下所示:
-
Player
类有一个hit
函数。当僵尸与玩家碰撞时,我们将调用它。 -
Zombie
类有一个hit
函数。当子弹与僵尸碰撞时,我们将调用它。 -
Pickup
类有一个gotIt
函数。当玩家与拾取物碰撞时,我们将调用它。
如果需要,回顾一下每个函数是如何工作的,以刷新你的记忆。我们现在需要做的是检测碰撞并调用适当的函数。
我们将使用 矩形交集 来检测碰撞。这种碰撞检测方法简单(特别是在 SFML 中)。我们将使用我们在 Pong 游戏中使用的相同技术。以下图像显示了矩形如何合理准确地表示僵尸和玩家:
我们将在三个代码部分中处理这个问题,这些部分将依次进行。它们都将放在我们游戏引擎的更新部分的末尾。
对于每一帧,我们需要知道以下三个问题的答案:
-
僵尸是否被射击?
-
玩家是否被僵尸触碰过?
-
玩家是否触碰了拾取物?
首先,让我们为 score
和 hiscore
添加一些额外的变量。然后,当僵尸被杀死时,我们可以更改它们。添加以下代码:
// Create a couple of pickups
Pickup healthPickup(1);
Pickup ammoPickup(2);
// About the game
int score = 0;
int hiScore = 0;
// The main game loop
while (window.isOpen())
现在,让我们首先检测僵尸是否与子弹发生碰撞。
僵尸是否被射击了?
以下代码可能看起来很复杂,但当我们逐步执行它时,我们会看到它并没有什么我们没有见过的。在调用更新拾取物后的代码中添加以下代码:
// Update the pickups
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
// Collision detection
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Stop the bullet
bullets[i].stop();
// Register the hit and see if it was a kill
if (zombies[j].hit())
{
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
}
}
}
}// End zombie being shot
在下一节中,我们将再次看到所有僵尸和子弹碰撞检测的代码。我们将分步骤进行,以便进行讨论。首先,注意前面代码中嵌套的for
循环的结构(部分代码已被删除),如下所示:
// Collision detection
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
...
...
...
}
}
代码对每个僵尸(从 0 到numZombies
)的每个子弹(从 0 到 99)进行循环。
在嵌套的for
循环中,我们执行以下操作。
我们使用以下代码检查当前子弹是否在飞行,以及当前僵尸是否仍然存活:
if (bullets[i].isInFlight() && zombies[j].isAlive())
假设僵尸存活且子弹在飞行,我们使用以下代码测试矩形交集:
if (bullets[i].getPosition().intersects(zombies[j].getPosition()))
如果当前子弹和僵尸发生碰撞,那么我们将采取一系列步骤,具体如下。
使用以下代码停止子弹:
// Stop the bullet
bullets[i].stop();
通过调用当前僵尸的hit
函数来记录与当前僵尸的碰撞。请注意,hit
函数返回一个布尔值,让调用代码知道僵尸是否已经死亡。这在上面的代码行中显示:
// Register the hit and see if it was a kill
if (zombies[j].hit()) {
在检测僵尸死亡且未刚刚伤害我们的if
块内部,执行以下操作:
-
将
score
加十。 -
如果玩家获得的分数超过了(击败了)
score
,则需要更改hiScore
。 -
将
numZombiesAlive
减一。 -
使用
(numZombiesAlive == 0)
检查所有僵尸是否都已死亡,如果是,则将state
更改为LEVELING_UP
。
这是我们在if(zombies[j].hit())
中讨论的代码块:
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0)
{
state = State::LEVELING_UP;
}
这样,僵尸和子弹的问题就解决了。你现在可以运行游戏并看到血液。当然,除非我们在下一章实现 HUD,否则你不会看到分数。
玩家是否被僵尸触碰了?
这段代码比僵尸和子弹碰撞检测代码要短得多,简单得多。在之前编写的代码之后添加以下突出显示的代码:
}// End zombie being shot
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
}
}
}// End player touched
在这里,我们使用一个for
循环遍历所有僵尸来检测僵尸是否与玩家发生碰撞。对于每个存活的僵尸,代码使用intersects
函数来测试与玩家的碰撞。当发生碰撞时,我们调用player.hit
。然后,我们通过调用player.getHealth
来检查玩家是否死亡。如果玩家的健康值等于或小于零,则将state
更改为GAME_OVER
。
你可以运行游戏,并且会检测到碰撞。然而,由于目前还没有 HUD 或音效,不清楚这是否正在发生。此外,我们需要做一些额外的工作来重置玩家死亡时的游戏,并开始新游戏。所以,尽管游戏可以运行,但现在的结果并不特别令人满意。我们将在接下来的两章中改进这一点。
玩家是否触摸了拾取物?
玩家和每个拾取物之间的碰撞检测代码如下。在之前添加的代码之后添加以下突出显示的代码:
}// End player touched
// Has the player touched health pickup
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
}
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
}// End updating the scene
上一段代码使用了两个简单的if
语句来检查玩家是否触摸了healthPickup
或ammoPickup
。
如果收集了生命拾取物,则player.increaseHealthLevel
函数使用healthPickup.gotIt
函数返回的值来增加玩家的生命值。
如果已经收集了弹药拾取物,则bulletsSpare
会增加ammoPickup.gotIt
返回的值。
重要提示
你现在可以运行游戏,杀死僵尸,并收集拾取物!注意,当你的生命值等于零时,游戏将进入GAME_OVER
状态并暂停。要重新开始,你需要按下Enter
键,然后输入一个介于 1 到 6 之间的数字。当我们实现 HUD、主屏幕和升级屏幕时,这些步骤对玩家来说将直观且简单。我们将在下一章中这样做。
摘要
这是一章忙碌的章节,但我们取得了很大的成就。我们不仅通过两个新类添加了子弹和拾取物到游戏中,而且还通过检测它们相互碰撞时,使所有对象都按预期进行交互。
尽管取得了这些成就,我们还需要做更多的工作来设置每场新游戏,并通过 HUD 给玩家提供反馈。在下一章中,我们将构建 HUD。
常见问题解答
这里有一些可能出现在你脑海中的问题:
Q) 有没有更好的碰撞检测方法?
A) 是的。有更多方法可以进行碰撞检测,包括但不限于以下方法。
-
你可以将对象分成多个矩形,这些矩形更适合精灵的形状。对于 C++来说,每帧检查数千个矩形是完全可行的。这尤其适用于你使用邻居检查等技术来减少每帧必要的测试数量时。
-
对于圆形对象,你可以使用半径重叠方法。
-
对于不规则多边形,你可以使用通过数算法。
如果你愿意,可以通过查看以下链接来回顾所有这些技术:
-
邻居检查:
gamecodeschool.com/essentials/collision-detection-neighbor-checking/
-
半径重叠方法:
gamecodeschool.com/essentials/collision-detection-radius-overlap/
-
跨越数算法:
gamecodeschool.com/essentials/collision-detection-crossing-number/
第十三章:第十二章:分层视图和实现 HUD
在本章中,我们将看到 SFML 视图的真正价值。我们将添加一大堆 SFML Text
对象,并像在 Timber!!!项目和 Pong 项目中之前做的那样操作它们。新的地方在于,我们将使用第二个视图实例来绘制 HUD。这样,HUD 将整齐地定位在主游戏动作的顶部,无论背景、玩家、僵尸和其他游戏对象在做什么。
在本章中,我们将做以下事情:
-
为主/游戏结束屏幕添加文本和背景
-
为等级提升屏幕添加文本
-
创建第二个视图
-
添加一个 HUD
添加所有文本和 HUD 对象
在本章中,我们将操作一些字符串。我们这样做是为了用必要的文本格式化 HUD 和等级提升屏幕。
在以下代码中添加额外的include
指令,以便我们可以创建一些sstream
对象来实现这一点:
#include <sstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
接下来,添加这段相当长但容易解释的代码。为了帮助识别你应该在哪里添加代码,新的代码被突出显示,而现有的代码没有被突出显示:
int score = 0;
int hiScore = 0;
// For the home/game over screen
Sprite spriteGameOver;
Texture textureGameOver = TextureHolder::GetTexture("graphics/background.png");
spriteGameOver.setTexture(textureGameOver);
spriteGameOver.setPosition(0, 0);
// Create a view for the HUD
View hudView(sf::FloatRect(0, 0, resolution.x, resolution.y));
// Create a sprite for the ammo icon
Sprite spriteAmmoIcon;
Texture textureAmmoIcon = TextureHolder::GetTexture(
"graphics/ammo_icon.png");
spriteAmmoIcon.setTexture(textureAmmoIcon);
spriteAmmoIcon.setPosition(20, 980);
// Load the font
Font font;
font.loadFromFile("fonts/zombiecontrol.ttf");
// Paused
Text pausedText;
pausedText.setFont(font);
pausedText.setCharacterSize(155);
pausedText.setFillColor(Color::White);
pausedText.setPosition(400, 400);
pausedText.setString("Press Enter \nto continue");
// Game Over
Text gameOverText;
gameOverText.setFont(font);
gameOverText.setCharacterSize(125);
gameOverText.setFillColor(Color::White);
gameOverText.setPosition(250, 850);
gameOverText.setString("Press Enter to play");
// LEVELING up
Text levelUpText;
levelUpText.setFont(font);
levelUpText.setCharacterSize(80);
levelUpText.setFillColor(Color::White);
levelUpText.setPosition(150, 250);
std::stringstream levelUpStream;
levelUpStream <<
"1- Increased rate of fire" <<
"\n2- Increased clip size(next reload)" <<
"\n3- Increased max health" <<
"\n4- Increased run speed" <<
"\n5- More and better health pickups" <<
"\n6- More and better ammo pickups";
levelUpText.setString(levelUpStream.str());
// Ammo
Text ammoText;
ammoText.setFont(font);
ammoText.setCharacterSize(55);
ammoText.setFillColor(Color::White);
ammoText.setPosition(200, 980);
// Score
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setFillColor(Color::White);
scoreText.setPosition(20, 0);
// Hi Score
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setFillColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
// Zombies remaining
Text zombiesRemainingText;
zombiesRemainingText.setFont(font);
zombiesRemainingText.setCharacterSize(55);
zombiesRemainingText.setFillColor(Color::White);
zombiesRemainingText.setPosition(1500, 980);
zombiesRemainingText.setString("Zombies: 100");
// Wave number
int wave = 0;
Text waveNumberText;
waveNumberText.setFont(font);
waveNumberText.setCharacterSize(55);
waveNumberText.setFillColor(Color::White);
waveNumberText.setPosition(1250, 980);
waveNumberText.setString("Wave: 0");
// Health bar
RectangleShape healthBar;
healthBar.setFillColor(Color::Red);
healthBar.setPosition(450, 980);
// The main game loop
while (window.isOpen())
之前的代码非常简单,没有什么新意。它基本上创建了一大堆 SFML Text
对象。它为它们分配颜色和大小,然后使用我们之前见过的函数来格式化它们的位置。
最重要的是要注意,我们创建了一个名为hudView
的另一个View
对象,并将其初始化以适应屏幕分辨率。
正如我们所见,主要的View
对象会随着玩家的移动而滚动。相比之下,我们永远不会移动hudView
。结果是,如果我们在我们绘制 HUD 元素之前切换到这个视图,我们将产生一种效果,即允许游戏世界在玩家 HUD 保持静止的情况下在其下方滚动。
小贴士
作为类比,你可以想象在电视屏幕上放置一张带有文字的透明塑料薄片。电视会继续播放移动的画面,而塑料薄片上的文字将保持在同一位置,无论下面发生什么。在下一个项目中,我们将进一步扩展这个概念,当时我们将分割屏幕并分离游戏世界的移动视图。
然而,接下来要注意的是,高分并没有以任何有意义的方式设置。我们需要等到下一章,当我们研究文件 I/O 时,才能保存和检索高分。
另一个值得注意的点是,我们声明并初始化了一个名为healthBar
的RectangleShape
,它将表示玩家剩余的生命值。这几乎与 Timber!!!项目中的时间条以相同的方式工作,除了它将代表生命值而不是时间。
在之前的代码中,有一个新的Sprite
实例称为ammoIcon
,它为我们将要绘制在屏幕左下角的子弹和弹夹统计信息提供了上下文。
虽然我们添加的大量代码没有新的或技术性的内容,但请务必熟悉细节——特别是变量名称——以便使本章的其余部分更容易理解。
更新 HUD
如您所预期的那样,我们将在代码的更新部分更新 HUD 变量。然而,我们不会在每一帧都这样做。这样做的原因是不必要的,而且还会减慢我们的游戏循环。
例如,考虑玩家杀死僵尸并获得更多分数的场景。无论包含分数的Text
对象是在千分之一、百分之一甚至十分之一秒内更新,玩家都不会察觉到任何区别。这意味着没有必要在每一帧重建我们为Text
对象设置的字符串。
因此,我们可以记录何时以及多久更新一次 HUD。添加以下高亮变量:
// Debug HUD
Text debugText;
debugText.setFont(font);
debugText.setCharacterSize(25);
debugText.setFillColor(Color::White);
debugText.setPosition(20, 220);
std::ostringstream ss;
// When did we last update the HUD?
int framesSinceLastHUDUpdate = 0;
// How often (in frames) should we update the HUD
int fpsMeasurementFrameInterval = 1000;
// The main game loop
while (window.isOpen())
在前面的代码中,我们有变量来跟踪自上次更新 HUD 以来经过的帧数,以及我们希望在 HUD 更新之间等待的帧间隔。
现在,我们可以使用这些新变量并在每一帧更新 HUD。然而,直到我们在下一章开始操作最终变量,例如wave
,我们才不会看到所有 HUD 元素的变化。
在游戏循环的更新部分添加以下高亮代码,如下所示:
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
// size up the health bar
healthBar.setSize(Vector2f(player.getHealth() * 3, 50));
// Increment the number of frames since the previous update
framesSinceLastHUDUpdate++;
// re-calculate every fpsMeasurementFrameInterval frames
if (framesSinceLastHUDUpdate > fpsMeasurementFrameInterval)
{
// Update game HUD text
std::stringstream ssAmmo;
std::stringstream ssScore;
std::stringstream ssHiScore;
std::stringstream ssWave;
std::stringstream ssZombiesAlive;
// Update the ammo text
ssAmmo << bulletsInClip << "/" << bulletsSpare;
ammoText.setString(ssAmmo.str());
// Update the score text
ssScore << "Score:" << score;
scoreText.setString(ssScore.str());
// Update the high score text
ssHiScore << "Hi Score:" << hiScore;
hiScoreText.setString(ssHiScore.str());
// Update the wave
ssWave << "Wave:" << wave;
waveNumberText.setString(ssWave.str());
// Update the high score text
ssZombiesAlive << "Zombies:" << numZombiesAlive;
zombiesRemainingText.setString(ssZombiesAlive.str());
framesSinceLastHUDUpdate = 0;
}// End HUD update
}// End updating the scene
在新代码中,我们更新了healthBar
精灵的大小,然后增加framesSinceLastHUDUpdate
变量。
接下来,我们开始一个if
块,测试framesSinceLastHUDUpdate
是否大于我们存储在fpsMeasurementFrameInterval
中的首选间隔。
在这个if
块内部发生所有操作。首先,我们为需要设置到Text
对象的每个字符串声明一个stringstream
对象。
然后,我们依次使用这些stringstream
对象,并使用setString
函数将结果设置到适当的Text
对象中。
最后,在退出if
块之前,将framesSinceLastHUDUpdate
重置为零,以便计数可以重新开始。
现在,当我们重新绘制场景时,新的值将出现在玩家的 HUD 中。
绘制 HUD、主界面和升级屏幕
以下三个代码块中的所有代码都放在我们的游戏循环的绘制阶段。我们所需做的只是在主游戏循环的绘制部分绘制适当的Text
对象。
在PLAYING
状态中,添加以下高亮代码:
//Draw the crosshair
window.draw(spriteCrosshair);
// Switch to the HUD view
window.setView(hudView);
// Draw all the HUD elements
window.draw(spriteAmmoIcon);
window.draw(ammoText);
window.draw(scoreText);
window.draw(hiScoreText);
window.draw(healthBar);
window.draw(waveNumberText);
window.draw(zombiesRemainingText);
}
if (state == State::LEVELING_UP)
{
}
在前面的代码块中需要注意的重要一点是我们切换到 HUD 视图。这导致所有元素都绘制在我们在每个 HUD 元素中给出的精确屏幕位置。它们永远不会移动。
在LEVELING_UP
状态中,添加以下高亮代码:
if (state == State::LEVELING_UP)
{
window.draw(spriteGameOver);
window.draw(levelUpText);
}
在PAUSED
状态中,添加以下高亮代码:
if (state == State::PAUSED)
{
window.draw(pausedText);
}
在GAME_OVER
状态中,添加以下高亮代码:
if (state == State::GAME_OVER)
{
window.draw(spriteGameOver);
window.draw(gameOverText);
window.draw(scoreText);
window.draw(hiScoreText);
}
现在,我们可以运行游戏并看到我们的 HUD 在游戏过程中更新:
以下截图显示了主/游戏结束屏幕上的高分和得分:
接下来,我们看到文本告诉玩家他们的升级选项是什么,尽管这些选项目前还没有任何作用:
这里,我们可以看到暂停屏幕上的一个有用的消息:
小贴士
SFML 的View
类比这个简单的 HUD 展示的功能更强大。要了解 SFML View
类的潜力以及它们的使用有多简单,请查看 SFML 网站上的View
教程,链接为www.sfml-dev.org/tutorials/2.5/graphics-view.php
。
摘要
这是一章快速而简单的章节。我们学习了如何使用sstream
显示不同类型变量所持有的值,然后学习了如何使用第二个 SFML View
对象在主游戏动作上方绘制它们。
现在僵尸竞技场几乎完成了。本章中所有的截图都显示了一个小竞技场,没有充分利用整个显示器。
在下一章,也就是这个项目的最后一章,我们将添加一些收尾工作,比如升级、音效和保存高分。竞技场的大小可以扩展到与显示器相同,甚至更大。
常见问题解答
这里有一个可能出现在你脑海中的问题:
Q) 我在哪里可以看到View
类更多实际应用的力量?
A) 在下载包中查看《僵尸竞技场》游戏的增强版。您可以使用光标键盘键来旋转和缩放游戏。警告!旋转场景会使控制变得笨拙,但您可以看到使用View
类可以完成的一些事情:
缩放和旋转功能仅通过在主游戏循环的输入处理部分编写几行代码就实现了。您可以在下载包的Zombie Arena Enhanced Version
文件夹中查看代码,或者从Runnable Games/Zombie Arena
文件夹中运行增强版。
第十四章:第十三章:音效、文件 I/O 和完成游戏
我们即将完成。这一简短的章节将展示我们如何使用 C++ 标准库轻松地操作硬盘上存储的文件,我们还将添加音效。当然,我们知道如何添加音效,但我们将讨论代码中 play
函数调用的确切位置。我们还将解决一些悬而未决的问题,以使游戏完整。
在本章中,我们将涵盖以下主题:
-
使用文件输入和文件输出保存和加载最高分
-
添加音效
-
允许玩家升级
-
创建多个永无止境的波次
保存和加载最高分
文件 fstream
。
首先,我们以与包含 sstream
相同的方式包含 fstream
:
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
现在,在 ZombieArena
文件夹中添加一个新的文件夹,命名为 gamedata
。然后,在此文件夹中右键单击并创建一个名为 scores.txt
的新文件。我们将在这个文件中保存玩家的最高分。你可以轻松地打开文件并添加分数。如果你这样做,请确保分数相当低,这样我们就可以轻松地测试击败这个分数是否会添加新的分数。完成操作后,务必关闭文件,否则游戏将无法访问它。
在下面的代码中,我们将创建一个名为 inputFile
的 ifstream
对象,并将我们刚刚创建的文件夹和文件作为参数传递给其构造函数。
if(inputFile.is_open())
检查文件是否存在且准备好读取。然后我们将文件内容放入 hiScore
并关闭文件。添加以下突出显示的代码:
// Score
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setColor(Color::White);
scoreText.setPosition(20, 0);
// Load the high score from a text file
std::ifstream inputFile("gamedata/scores.txt");
if (inputFile.is_open())
{
// >> Reads the data
inputFile >> hiScore;
inputFile.close();
}
// Hi Score
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
现在,我们可以处理保存可能的新最高分。在处理玩家健康值小于或等于零的代码块中,我们需要创建一个名为 outputFile
的 ofstream
对象,将 hiScore
的值写入文本文件,然后关闭文件,如下所示:
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream outputFile("gamedata/scores.txt");
// << writes the data
outputFile << hiScore;
outputFile.close();
}
}
}// End player touched
你可以玩游戏,你的最高分将被保存。退出游戏后,如果你再次玩游戏,会发现你的最高分仍然在那里。
让我们制造一些噪音。
准备音效
在本节中,我们将创建所有需要的 SoundBuffer
和 Sound
对象,以便为游戏添加一系列音效。
首先,添加所需的 SFML #include
语句:
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
现在,继续添加七个 SoundBuffer
和 Sound
对象,这些对象将加载和准备我们在 第八章**,SFML 视图 – 开始僵尸射击游戏 中准备的七个音效文件:
// When did we last update the HUD?
int framesSinceLastHUDUpdate = 0;
// What time was the last update
Time timeSinceLastUpdate;
// How often (in frames) should we update the HUD
int fpsMeasurementFrameInterval = 1000;
// Prepare the hit sound
SoundBuffer hitBuffer;
hitBuffer.loadFromFile("sound/hit.wav");
Sound hit;
hit.setBuffer(hitBuffer);
// Prepare the splat sound
SoundBuffer splatBuffer;
splatBuffer.loadFromFile("sound/splat.wav");
Sound splat;
splat.setBuffer(splatBuffer);
// Prepare the shoot sound
SoundBuffer shootBuffer;
shootBuffer.loadFromFile("sound/shoot.wav");
Sound shoot;
shoot.setBuffer(shootBuffer);
// Prepare the reload sound
SoundBuffer reloadBuffer;
reloadBuffer.loadFromFile("sound/reload.wav");
Sound reload;
reload.setBuffer(reloadBuffer);
// Prepare the failed sound
SoundBuffer reloadFailedBuffer;
reloadFailedBuffer.loadFromFile("sound/reload_failed.wav");
Sound reloadFailed;
reloadFailed.setBuffer(reloadFailedBuffer);
// Prepare the powerup sound
SoundBuffer powerupBuffer;
powerupBuffer.loadFromFile("sound/powerup.wav");
Sound powerup;
powerup.setBuffer(powerupBuffer);
// Prepare the pickup sound
SoundBuffer pickupBuffer;
pickupBuffer.loadFromFile("sound/pickup.wav");
Sound pickup;
pickup.setBuffer(pickupBuffer);
// The main game loop
while (window.isOpen())
现在,七个音效已经准备好播放。我们只需要确定代码中每个 play
函数调用的确切位置。
升级
我们将要添加的代码允许玩家在波次之间升级。正是因为我们已经完成的工作,所以这很容易实现。
将以下突出显示的代码添加到处理玩家输入的 LEVELING_UP
状态中:
// Handle the LEVELING up state
if (state == State::LEVELING_UP)
{
// Handle the player LEVELING up
if (event.key.code == Keyboard::Num1)
{
// Increase fire rate
fireRate++;
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num2)
{
// Increase clip size
clipSize += clipSize;
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num3)
{
// Increase health
player.upgradeHealth();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num4)
{
// Increase speed
player.upgradeSpeed();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num5)
{
// Upgrade pickup
healthPickup.upgrade();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num6)
{
// Upgrade pickup
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
每当玩家清空一波僵尸时,他们都可以升级。然而,我们目前还不能增加僵尸的数量或升级的大小。
在我们刚刚添加的代码之后,在LEVELING_UP
状态的下一部分,修改当状态从LEVELING_UP
变为PLAYING
时运行的代码。
这里是完整的代码。我已经突出显示了新添加或略有修改的行。
添加或修改以下突出显示的代码:
if (event.key.code == Keyboard::Num6)
{
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
// Increase the wave number
wave++;
// Prepare the level
// We will modify the next two lines later
arena.width = 500 * wave;
arena.height = 500 * wave;
arena.left = 0;
arena.top = 0;
// Pass the vertex array by reference
// to the createBackground function
int tileSize = createBackground(background, arena);
// Spawn the player in the middle of the arena
player.spawn(arena, resolution, tileSize);
// Configure the pick-ups
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Create a horde of zombies
numZombies = 5 * wave;
// Delete the previously allocated memory (if it exists)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Play the powerup sound
powerup.play();
// Reset the clock so there isn't a frame jump
clock.restart();
}
}// End LEVELING up
之前的代码首先增加wave
变量。然后,代码被修改以使僵尸的数量和竞技场的大小与wave
的新值成比例。最后,我们添加对powerup.play()
的调用以播放升级音效。
重新开始游戏
我们已经通过wave
变量的值确定了竞技场的大小和僵尸的数量。我们还必须重置弹药和枪相关的变量,以及在每场新游戏开始时将wave
和score
设置为零。在游戏循环的事件处理部分找到以下代码,并添加以下突出显示的代码:
// Start a new game while in GAME_OVER state
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
wave = 0;
score = 0;
// Prepare the gun and ammo for next game
currentBullet = 0;
bulletsSpare = 24;
bulletsInClip = 6;
clipSize = 6;
fireRate = 1;
// Reset the player's stats
player.resetPlayerStats();
}
现在,我们可以玩游戏了,玩家可以变得更加强大,僵尸将在不断扩大的竞技场中越来越多——直到它们死亡。然后,游戏重新开始。
播放其余的声音
现在,我们将把其余的调用添加到play
函数中。我们将逐个处理它们,因为精确地确定它们的位置对于在正确的时间播放它们至关重要。
在玩家装弹时添加音效
在以下三个地方添加以下突出显示的代码,以便在玩家按下R键尝试装弹时播放适当的reload
或reloadFailed
声音:
if (state == State::PLAYING)
{
// Reloading
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Plenty of bullets. Reload.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
reload.play();
}
else if (bulletsSpare > 0)
{
// Only few bullets left
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
reload.play();
}
else
{
// More here soon?!
reloadFailed.play();
}
}
}
现在当玩家装弹或尝试装弹时,他们会得到一个可听见的响应。让我们继续播放射击声音。
制作射击声音
在处理玩家点击左鼠标按钮的代码的末尾添加以下突出显示的shoot.play()
调用:
// Fire a bullet
if (sf::Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Pass the centre of the player and crosshair
// to the shoot function
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
shoot.play();
bulletsInClip--;
}
}// End fire a bullet
游戏现在将播放令人满意的射击声音。接下来,我们将播放玩家被僵尸击中的声音。
当玩家被击中时播放声音
在以下代码中,我们将hit.play
调用包裹在一个测试中,以查看player.hit
函数是否返回 true。请记住,player.hit
函数检查前 100 毫秒内是否有击中记录。这将产生快速重复的咚咚声,但不会快到声音模糊成一个噪音。
添加以下代码中突出显示的hit.play
调用:
// Have any zombies touched the player
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// More here later
hit.play();
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream OutputFile("gamedata/scores.txt");
OutputFile << hiScore;
OutputFile.close();
}
}
}// End player touched
当僵尸接触到玩家时,玩家会听到一个不祥的咚咚声,如果僵尸继续接触玩家,这个声音每秒会重复大约五次。这个逻辑包含在Player
类的hit
函数中。
在拾取物品时播放声音
当玩家拾取生命恢复物品时,我们将播放常规拾取声音。然而,当玩家拾取弹药时,我们将播放装弹声音效。
在适当的碰撞检测代码中添加播放声音的两个调用:
// Has the player touched health pickup
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
// Play a sound
pickup.play();
}
// Has the player touched ammo pickup
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
// Play a sound
reload.play();
}
当僵尸被击中时发出噗嗤声
在检测子弹与僵尸碰撞的代码段末尾添加对 splat.play
的调用:
// Have any zombies been shot?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Stop the bullet
bullets[i].stop();
// Register the hit and see if it was a kill
if (zombies[j].hit()) {
// Not just a hit but a kill too
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// When all the zombies are dead (again)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
// Make a splat sound
splat.play();
}
}
}
}// End zombie being shot
你现在可以播放完成的游戏,并观察每一波僵尸和竞技场数量的增加。仔细选择你的等级提升:
恭喜!
摘要
我们已经完成了僵尸竞技场游戏。这是一段相当漫长的旅程。我们学习了许多 C++ 基础知识,例如引用、指针、面向对象编程和类。此外,我们还使用了 SFML 来管理摄像机(视图)、顶点数组和碰撞检测。我们学习了如何使用精灵图来减少对 window.draw
的调用次数并提高帧率。使用 C++ 指针、STL 和一点面向对象编程,我们构建了一个单例类来管理我们的纹理。在下一个项目中,我们将扩展这个想法来管理我们游戏的所有资产。
在本书的倒数第二个项目中,我们将发现粒子效果、方向性声音和分屏合作游戏。在 C++ 中,我们将遇到继承、多态以及一些新的概念。
常见问题解答
这里有一些可能出现在你脑海中的问题:
Q) 尽管使用了类,但我发现代码变得越来越长且难以管理。
A) 最大的问题之一是我们代码的结构。随着我们学习更多的 C++,我们也会学习如何使代码更易于管理,并且通常更短。我们将在下一个项目和最终项目中这样做。到本书结束时,你将了解一些你可以用来管理你代码的策略。
Q) 声音效果听起来有点平淡和不真实。如何改进它们?
A) 一种显著提高玩家从声音中获得的感觉的方法是使声音具有方向性,并根据声音源与玩家角色的距离改变音量。我们将在下一个项目中使用 SFML 的高级声音功能。
第十五章:第十四章:抽象和代码管理 – 更好地利用面向对象编程
在本章中,我们将首次查看本书的最后一项项目。我们将构建的项目将使用诸如方向性声音等高级功能,这些声音似乎与玩家的位置相关。它还将具有分屏合作游戏。此外,该项目还将引入着色器的概念,这些着色器是用另一种语言编写的程序,可以直接在图形卡上运行。到第十八章,“粒子系统和着色器”结束时,你将拥有一个完全功能的多玩家平台游戏,其风格模仿了热门经典游戏托马斯·瓦瑟德。
本章的重点将是启动项目并探讨如何构建代码以更好地利用面向对象编程。以下是本章将涵盖的主题细节:
-
介绍最终项目,托马斯·瓦瑟德迟到,包括游戏玩法功能和项目资源
-
与之前项目相比,我们将如何改进代码结构的详细讨论
-
编写托马斯·瓦瑟德迟到游戏引擎的代码
-
实现分屏功能
托马斯·瓦瑟德迟到游戏
小贴士
在这一点上,如果你还没有的话,我建议你去看一下托马斯·瓦瑟德独自一人的视频,store.steampowered.com/app/220780/
。
注意到简单但美观的图形。视频还展示了各种游戏挑战,例如使用角色的不同属性(高度、跳跃、力量等)。为了保持我们的游戏简单而不失去挑战性,我们将比托马斯·瓦瑟德更少的谜题功能,但将增加需要两名玩家合作游戏的额外挑战。为了确保游戏不会太简单,我们还将让玩家必须赶时间打败时钟,这就是为什么我们的游戏名字叫托马斯·瓦瑟德迟到。
托马斯·瓦瑟德迟到游戏的特点
我们的游戏将不会像我们试图模仿的杰作那样先进,但它将拥有丰富的令人兴奋的游戏玩法特点,例如以下内容:
-
一个从适合该级别挑战的时间开始倒计时的时钟。
-
火坑会根据玩家的位置发出咆哮声,如果玩家掉入其中,则会在起点重生。水坑有相同的效果,但没有方向性音效。
-
合作游戏。两名玩家必须在规定时间内将他们的角色带到终点。他们需要经常合作,因为跳得较矮、跳跃能力较低的鲍勃需要站在他的朋友(托马斯)的头上。
-
玩家可以选择在全屏和分屏之间切换,以便他们可以自己尝试控制两个角色。
-
每个关卡都将设计并从文本文件中加载。这将使得设计多样化和大量的关卡变得容易。
看一下以下注释的截图,看看游戏中的某些功能是如何运作的,以及构成游戏的游戏组件/资产:
让我们来看看这些功能中的每一个,并描述一些更多:
-
前面的截图显示了一个简单的 HUD,其中详细说明了关卡编号以及玩家(们)失败并必须重新开始关卡之前剩余的秒数。
-
你还可以清楚地看到分屏合作模式的效果。记住,这是可选的。单个玩家可以全屏玩游戏,同时切换摄像头焦点在托马斯和鲍勃之间。
-
在前面的截图(尤其是在印刷品中)中并不非常清晰,但当角色死亡时,它们将以星爆/烟花般的粒子效果爆炸。
-
水和火砖可以被战略性地放置,使关卡有趣,同时也迫使角色之间进行合作。关于这一点,将在第十六章中详细说明,构建可玩关卡和碰撞检测。
-
接下来,注意托马斯和鲍勃。他们不仅身高不同,而且跳跃能力差异很大。这意味着鲍勃在跳跃时依赖于托马斯,关卡可以设计成迫使托马斯走一条特定的路线以避免他“撞头”。
-
此外,火砖会发出咆哮声。这些声音将与托马斯的位置相关。它们不仅会根据方向从左或右扬声器发出,而且当托马斯靠近或远离声音源时,声音也会变得更大或更小。
-
最后,在前面的注释截图中,你可以看到背景。为什么不比较一下它与
background.png
文件(本章后面将展示)的外观?你会发现它们相当不同。我们将在第十八章中,粒子系统和着色器,使用 OpenGL 着色器效果来实现背景中移动的、几乎像冒泡的效果。
所有这些功能都值得展示更多截图,这样我们可以在编写 C++代码时记住最终产品。
下面的截图显示了托马斯和鲍勃到达一个火坑,鲍勃没有帮助就无法跳过去:
下面的截图显示了鲍勃和托马斯合作清除一个危险的跳跃:
下面的截图展示了我们如何设计需要“信仰跳跃”才能达到目标的谜题:
下面的截图展示了我们如何设计几乎任何大小的压抑洞穴系统。我们还可以设计鲍勃和托马斯被迫分开走不同路线的关卡:
创建项目
创建 Thomas Was Late 项目将遵循我们在前三个项目中使用的相同程序。由于创建项目是一个稍微有些繁琐的过程,所以我将再次详细说明所有步骤。对于更多细节和图片,请参考第一章中设置 Timber!!! 项目的步骤,C++、SFML、Visual Studio 和开始第一个游戏:
-
启动 Visual Studio 并点击创建新项目按钮。如果你已经打开了另一个项目,你可以选择文件 | 新建项目。
-
在随后显示的窗口中,选择控制台应用程序并点击下一步按钮。然后你会看到配置你的新项目窗口。
-
在项目名称字段中的TWL。
-
在
VS Projects
文件夹中。 -
选择将解决方案和项目放在同一目录下的选项。
-
完成这些步骤后,点击创建。
-
我们现在将配置项目以使用我们放在
SFML
文件夹中的 SFML 文件。从主菜单中选择项目 | TWL 属性…。在这个阶段,你应该已经打开了TWL 属性页窗口。 -
在TWL 属性页窗口中,执行以下步骤。从配置:下拉菜单中选择所有配置。
-
现在,从左侧菜单中选择C/C++然后常规。
-
现在,定位到
\SFML\include
。如果你将SFML
文件夹安装在 D 驱动器上,需要输入的完整路径是D:\SFML\include
。如果你的 SFML 安装在不同的驱动器上,请相应地更改路径。 -
点击应用以保存到目前为止的配置。
-
现在,仍然在这个窗口中,执行以下步骤。从左侧菜单中选择链接器然后常规。
-
现在,找到
SFML
文件夹的位置,然后是\SFML\lib
。所以,如果你将SFML
文件夹安装在 D 驱动器上,需要输入的完整路径是D:\SFML\lib
。如果你的 SFML 安装在不同的驱动器上,请相应地更改路径。 -
点击应用以保存到目前为止的配置。
-
接下来,仍然在这个窗口中,执行以下步骤。将配置:下拉菜单切换到调试,因为我们将在调试模式下运行和测试 Pong。
-
选择链接器然后输入。
-
找到
sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-network-d.lib;sfml-audio-d.lib;
。请格外小心地将光标放在编辑框当前内容的起始位置,以免覆盖任何已经存在的文本。 -
点击确定。
-
点击应用然后确定。
那么项目属性已经配置好,准备就绪。现在,我们需要按照以下步骤将 SFML .dll
文件复制到主项目目录中:
-
我的主要项目目录是
D:\VS Projects\TWL
。这个文件夹是在之前的步骤中由 Visual Studio 创建的。如果你将你的Projects
文件夹放在其他地方,请在这里执行此步骤。我们需要复制到项目文件夹中的文件位于我们的SFML\bin
文件夹中。为这两个位置打开一个窗口,并突出显示所有的.dll
文件。 -
现在,将高亮显示的文件复制并粘贴到项目中。
项目现在已经设置好,准备启动。
项目的资源
这个项目中的资源比 Zombie Arena 游戏更加众多和多样化。通常,资源包括屏幕上的文字字体、跳跃、达到目标或远处火的声音等不同动作的声音效果,以及当然,托马斯和鲍勃的图形以及所有背景瓦片的精灵图集。
游戏所需的全部资源都包含在下载包中。它们可以在Chapter 14/graphics
和Chapter 14/sound
文件夹中找到。
除了我们已知的图形、声音和字体之外,这个游戏还有两种新的资源类型。它们是关卡设计文件和 GLSL 着色器程序。让我们了解它们各自的详情。
游戏关卡设计
级别都是在一个文本文件中创建的。通过使用数字 0 到 3,我们可以构建挑战玩家的关卡设计。所有的关卡设计都在与其它资源相同的目录下的levels
文件夹中。现在你可以随意查看其中一个,但我们将在第十八章粒子系统和着色器中详细探讨它们。
除了这些关卡设计资源之外,我们还有一种特殊的图形资源类型,称为着色器。
GLSL 着色器
下载包中的Chapter 14/shaders
文件夹。
图形资源概览
图形资源构成了我们游戏场景的各个部分。如果你查看图形资源,应该很清楚它们在我们游戏中将被用于何处:
如果tiles_sheet
图形中的瓦片看起来与游戏截图略有不同,这是因为它们部分透明,背景透过会改变它们的样子。如果背景图形看起来与游戏截图中的实际背景完全不同,那是因为我们将编写的着色器程序将操纵每个像素,每一帧,以创建一种“熔化”效果。
声音资源概览
声音文件都是.wav
格式。这些文件包含我们在游戏中的某些事件中播放的声音效果。它们如下所示:
-
fallinfire.wav
:当玩家的头部进入火中且玩家没有逃脱机会时播放的声音。 -
fallinwater.wav
:水有与火相同的效果:死亡。这个声音效果会通知玩家他们需要从关卡开始处重新开始。 -
fire1.wav
:这个声音效果是单声道的。它将在玩家距离火瓦片的不同距离处播放不同音量的声音,并且根据玩家是否位于火瓦片的左侧或右侧,通过不同的扬声器播放。显然,我们需要学习更多技巧来实现这一功能。 -
jump.wav
:当玩家跳跃时,一个令人愉悦(略带可预测性)的欢呼声。 -
reachgoal.wav
:当玩家(们)将托马斯和鲍勃两个角色都带到目标方块时,发出的令人愉悦的胜利声音。
音效非常直接,你可以轻松地创建自己的。如果你打算替换fire1.wav
文件,请确保以单声道(不是立体声)格式保存你的声音。原因将在第十七章中解释,声音空间化和 HUD。
将资源添加到项目中
一旦你决定使用哪些资源,就是时候将它们添加到项目中了。以下说明将假设你使用的是本书下载包中提供的所有资源。
如果你使用的是自己的资源,只需用你自己的相应声音或图形文件替换,使用完全相同的文件名。让我们开始吧:
-
浏览到
D:\VS Projects\TWL
文件夹。 -
在此文件夹内创建五个新的文件夹,分别命名为
graphics
、sound
、fonts
、shaders
和levels
。 -
从下载包中,将
Chapter 14/graphics
目录下的全部内容复制到D:\VS Projects\TWL\graphics
文件夹。 -
从下载包中,将
Chapter 14/sound
目录下的全部内容复制到D:\VS Projects\TWL\sound
文件夹。 -
现在,在您的网络浏览器中访问
www.dafont.com/roboto.font
并下载Roboto Light字体。 -
解压下载内容,并将
Roboto-Light.ttf
文件添加到D:\VS Projects\TWL\fonts
文件夹。 -
从下载包中,将
Chapter 12/levels
目录下的全部内容复制到D:\VS Projects\TWL\levels
文件夹。 -
从下载包中,将
Chapter 12/shaders
目录下的全部内容复制到D:\VS Projects\TWL\shaders
文件夹。
现在我们有一个新的项目,以及整个项目所需的所有资源,我们可以谈谈我们将如何构建游戏引擎代码。
构建托马斯迟到的代码结构
尽管我们已经采取措施来减少问题,但项目与项目之间的问题却越来越严重,其中一个问题是代码变得越来越长且难以管理。面向对象编程(OOP)允许我们将项目分解成逻辑上和可管理的块,称为类。
通过引入Engine
类,我们将大幅提高本项目代码的可管理性。除了其他功能外,Engine
类将包含三个私有函数。这些是input
、update
和draw
。这些听起来应该非常熟悉。每个这些函数都将包含之前在main
函数中的一部分代码。每个这些函数都将位于自己的代码文件中,即Input.cpp
、Update.cpp
和Draw.cpp
,分别。
Engine
类还将有一个公共函数,可以通过Engine
实例调用。这个函数是run
,它将负责在每一帧游戏中分别调用input
、update
和draw
:
此外,因为我们已经将游戏引擎的主要部分抽象到了Engine
类中,我们还可以将许多变量从main
移动到Engine
中,使其成为成员。要启动我们的游戏引擎,我们只需要创建一个Engine
实例并调用它的run
函数。以下是一个超级简单的main
函数的预览:
int main()
{
// Declare an instance of Engine
Engine engine;
// Start the engine
engine.run();
// Quit in the usual way when the engine is stopped
return 0;
}
小贴士
还不要添加之前的代码。
为了使我们的代码更加易于管理和阅读,我们还将把加载关卡和碰撞检测等大型任务的责任抽象到单独的函数(在单独的代码文件中)。这两个函数是loadLevel
和detectCollisions
。我们还将编写其他函数来处理托马斯迟到项目的一些新功能。我们将详细讨论它们,在它们出现时。
为了进一步利用面向对象编程,我们将把游戏的一些区域的责任完全委托给新的类。你可能还记得,在之前的项目中,声音和 HUD 代码相当长。我们将构建一个SoundManager
和HUD
类来以更整洁的方式处理这些方面。它们是如何工作的,我们将在实现它们时进行深入探讨。
游戏关卡本身也比之前的游戏更加深入,因此我们也将编写一个LevelManager
类。
如你所预期,可玩角色也将通过类来创建。然而,对于这个项目,我们将学习更多 C++知识,并实现一个包含托马斯和鲍勃所有常见功能的PlayableCharacter
类。然后,Thomas
和Bob
类将继承这些常见功能,同时实现它们自己的独特功能和能力。这种技术,也许不出所料,被称为继承。我将在下一章中详细介绍继承:第十五章,高级面向对象编程 – 继承和多态。
我们还将实现几个其他类来执行特定的责任。例如,我们将使用粒子系统制作一些漂亮的爆炸效果。你可能已经猜到,为了做到这一点,我们将编写一个Particle
类和一个ParticleSystem
类。所有这些类都将有作为Engine
类成员的实例。这样做将使游戏的所有功能都可通过游戏引擎访问,但将细节封装到适当的类中。
小贴士
注意,尽管我们采用了这些新技术来分离代码的不同方面,但到这个项目的最后,我们仍然会有些不太容易管理的类。本书的最终项目,虽然是一个更简单的射击游戏,将探索一种组织代码的新方法,使其易于管理。
在我们继续查看将创建Engine
类的实际代码之前,最后要提到的是,我们将完全不加修改地重用我们为僵尸竞技场游戏讨论和编写的TextureHolder
类。
构建游戏引擎
正如我们在上一节中建议的,我们将编写一个名为Engine
的类,该类将控制并绑定《托马斯迟到了》游戏的不同部分。
我们首先要做的是将上一个项目中的TextureHolder
类应用到这个项目中。
重新使用 TextureHolder 类
我们在《僵尸竞技场》游戏中讨论并编写的TextureHolder
类也将在这个项目中很有用。虽然我们可以直接从上一个项目中添加文件(TextureHolder.h
和TextureHolder.cpp
),而不需要重新编码或重新创建文件,但我不想假设你直接跳到了这个项目。以下是非常简短的说明,以及我们需要创建TextureHolder
类所需的完整代码列表。如果你想了解该类或代码的解释,请参阅第十章,指针、标准模板库和纹理管理。
小贴士
如果你完成了上一个项目,并且确实想添加《僵尸竞技场》项目中的类,请按照以下步骤操作。在上一项目的TextureHolder.h
中右键点击并选择它。在上一项目的TextureHolder.cpp
中右键点击并选择它。你现在可以在本项目中使用TextureHolder
类。请注意,这些文件在项目之间是共享的,任何更改都将影响两个项目。
要从头创建TextureHolder
类,右键点击TextureHolder.h
。最后,点击添加按钮。
将以下代码添加到TextureHolder.h
中:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
class TextureHolder
{
private:
// A map container from the STL,
// that holds related pairs of String and Texture
std::map<std::string, sf::Texture> m_Textures;
// A pointer of the same type as the class itself
// the one and only instance
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static sf::Texture& GetTexture(std::string const& filename);
};
#endif
右键点击TextureHolder.cpp
。最后,点击添加按钮。
将以下代码添加到TextureHolder.cpp
中:
#include "TextureHolder.h"
#include <assert.h>
using namespace sf;
using namespace std;
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
sf::Texture& TextureHolder::GetTexture(std::string const& filename)
{
// Get a reference to m_Textures using m_S_Instance
auto& m = m_s_Instance->m_Textures;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in file name
auto keyValuePair = m.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end())
{
// Yes
// Return the texture,
// the second part of the kvp, the texture
return keyValuePair->second;
}
else
{
// File name not found
// Create a new key value pair using the filename
auto& texture = m[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
// Return the texture to the calling code
return texture;
}
}
现在,我们可以继续我们的新Engine
类。
Coding Engine.h
和往常一样,我们将从头文件开始,它包含函数声明和成员变量。请注意,我们将在整个项目中重新访问此文件以添加更多函数和成员变量。在这个阶段,我们将只添加必要的代码。
右键点击Engine.h
。最后,点击Engine
类。
添加以下成员变量以及函数声明。其中许多我们在其他项目中已经见过,一些在《托马斯迟到了》代码结构部分中讨论过。请注意函数和变量名,以及它们是私有还是公共的。将以下代码添加到Engine.h
文件中,然后我们将讨论它:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
// The force pushing the characters down
const int GRAVITY = 300;
// A regular RenderWindow
RenderWindow m_Window;
// The main Views
View m_MainView;
View m_LeftView;
View m_RightView;
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture
// for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
// Start in full screen (not split) mode
bool m_SplitScreen = false;
// Time left in the current level (seconds)
float m_TimeRemaining = 10;
Time m_GameTimeTotal;
// Is it time for a new/first level?
bool m_NewLevelRequired = true;
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
public:
// The Engine constructor
Engine();
// Run will call all the private functions
void run();
};
下面是所有私有变量和函数的完整列表。在适当的地方,我会花更多的时间来解释:
-
TextureHolder th
:TextureHolder
类的唯一实例。 -
TILE_SIZE
:一个有用的常量,提醒我们精灵图集中的每个图块宽度为 50 像素,高度为 50 像素。 -
VERTS_IN_QUAD
:一个有用的常量,可以使我们对VertexArray
的操作更不容易出错。实际上,一个四边形有四个顶点。现在,我们不要忘记它。 -
GRAVITY
:一个表示游戏角色每秒将被向下推多少像素的常量整数值。一旦游戏完成,这是一个很有趣的值来玩。我们在这里将其初始化为 300,因为这对我们的初始关卡设计来说效果很好。 -
m_Window
:我们所有项目中都有的常规RenderWindow
对象。 -
SFML
View
对象,m_MainView
、m_LeftView
、m_RightView
、m_BGMainView
:m_BGLeftView
、m_BGRightView
和m_HudView
:前三个View
对象用于全屏视图以及游戏的左右和分割屏幕视图。我们还有一个单独的 SFMLView
对象用于这三个视图,它将绘制背景。最后一个View
对象,m_HudView
,将绘制在其他六个视图的适当组合之上,以显示得分、剩余时间和对玩家的任何消息。拥有七个不同的View
对象可能意味着复杂性,但当你看到随着章节的进展我们如何处理它们时,你会发现它们相当简单。我们将在本章结束时解决整个分割屏幕/全屏的难题。 -
Sprite m_BackgroundSprite
和Texture m_BackgroundTexture
:有些可以预见,这个 SFML Sprite 和 Texture 的组合将用于显示和保存来自图形资源文件夹的背景图形。 -
m_Playing
:这个布尔值将让游戏引擎知道关卡是否已经开始(通过按下 Enter 键)。一旦开始,玩家没有暂停游戏的选择。 -
m_Character1
:当屏幕全屏时,应该以托马斯(m_Character1
= true
)为中心,还是以鲍勃(m_Character1 = false
)为中心?最初,它被初始化为 true,以托马斯为中心。 -
m_SplitScreen
:这个变量用于确定当前正在玩的游戏是否处于分割屏幕模式。我们将使用这个变量来决定如何确切地使用我们之前声明过的所有视图对象。 -
m_TimeRemaining
变量:这个float
变量保存了到达当前关卡目标剩余的时间(以秒为单位)。在之前的代码中,它被设置为 10 用于测试,直到我们为每个关卡设置一个特定的时间。 -
m_GameTimeTotal
变量:这个变量是一个 SFMLTime
对象。它记录了游戏已经进行了多长时间。 -
m_NewLevelRequired
布尔变量:这个变量关注玩家是否刚刚完成或失败了某个关卡。然后我们可以使用它来触发加载下一个关卡或重新开始当前关卡。 -
input
函数:这个函数将处理所有玩家的输入,在这个游戏中,输入完全来自键盘。乍一看,它似乎直接处理所有键盘输入。然而,在这个游戏中,我们将处理直接影响托马斯或鲍勃的键盘输入,这些输入在Thomas
和Bob
类中。此函数还将处理退出、切换到分屏和其他键盘输入等操作。 -
update
函数:这个函数将执行我们之前在main
函数更新部分所做的所有工作。我们还将从update
函数中调用其他一些函数,以保持代码的整洁。如果你回顾一下代码,你会看到它接收一个float
参数,该参数将保存自上一帧以来经过的秒数的分数。这当然正是我们更新所有游戏对象所需要的。 -
draw
函数:这个函数将包含之前项目中主函数绘图部分的所有代码。然而,当我们查看使用 SFML 绘图的其他方法时,我们将有一些绘图代码不会保留在这个函数中。我们将在学习第十八章“粒子系统和着色器”时看到这段新代码。
现在,让我们运行所有公共函数:
-
Engine
构造函数:正如我们所期待的,当我们首次声明Engine
实例时,这个函数将被调用。它将执行类的所有设置和初始化。我们将在稍后编写Engine.cpp
文件时看到具体内容。 -
run
函数:这是我们唯一需要调用的公共函数。它将触发input
、update
和draw
的执行,并为我们完成所有工作。
接下来,我们将看到所有这些函数的定义和一些变量的实际应用。
编写 Engine.cpp
在我们之前的所有类中,我们都将所有函数定义放入以类名命名的.cpp
文件中。由于我们这个项目的目标是使代码更易于管理,所以我们采取了一些不同的做法。
在Engine.cpp
文件中,我们将放置构造函数(Engine
)和公共的run
函数。其余的函数将放在它们自己的.cpp
文件中,文件名将清楚地表明哪个函数放在哪里。如果我们向包含Engine
类函数定义的所有文件顶部添加适当的包含指令(#include "Engine.h"
),这不会成为编译器的问题。
让我们从编写Engine
类代码并运行Engine.cpp
文件开始。右键单击Engine.cpp
。最后,单击Engine
类的.cpp
文件。
编写 Engine 类构造函数定义
这个函数的代码将放在我们最近创建的Engine.cpp
文件中。
添加以下代码后,我们就可以讨论它了:
#include "Engine.h"
Engine::Engine()
{
// Get the screen resolution
// and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the full screen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Initialize the split-screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
}
我们之前已经看到过很多这样的代码。例如,有获取屏幕分辨率以及创建 RenderWindow
的常规代码行。在之前的代码末尾,我们使用现在熟悉的代码来加载一个纹理并将其分配给 Sprite。在这种情况下,我们正在加载 background.png
纹理并将其分配给 m_BackgroundSprite
。
需要解释的是在四次调用 setViewport
函数之间的代码。setViewport
函数将屏幕的一部分分配给一个 SFML View
对象。然而,它不使用像素坐标,而是使用比例。在这里,“1”代表整个屏幕(宽度或高度)。每次调用 setViewport
的前两个值是起始位置(水平然后垂直),而最后两个是结束位置。
注意到 m_LeftView
和 m_BGLeftView
放置在完全相同的位置,即从屏幕的虚拟最左侧(0.001)开始,延伸到中心的两千分之一(0.498)。
m_RightView
和 m_BGRightView
也位于彼此的完全相同的位置,从之前的两个 View
对象的右侧(0.5)开始,延伸到几乎最右侧(0.998)。
此外,所有视图在屏幕顶部和底部都留有一小段间隙。当我们将这些 View
对象绘制到屏幕上,在白色背景之上时,它将在屏幕两侧之间产生一条细白线,以及边缘的细白边框。
我尝试在以下图中表示这个效果:
理解它的最好方法是完成这一章,运行代码,并看到它的实际效果。
编写 run
函数定义的代码
这个函数的代码将放入我们最近创建的 Engine.cpp
文件中。
在之前的构造函数代码之后立即添加以下代码:
void Engine::run()
{
// Timing
Clock clock;
while (m_Window.isOpen())
{
Time dt = clock.restart();
// Update the total game time
m_GameTimeTotal += dt;
// Make a decimal fraction from the delta time
float dtAsSeconds = dt.asSeconds();
// Call each part of the game loop in turn
input();
update(dtAsSeconds);
draw();
}
}
run
函数是我们引擎的中心;它启动所有其他部分。首先,我们声明一个 Clock
对象。接下来,我们有熟悉的 while(window.isOpen())
循环,它创建游戏循环。在这个 while 循环内部,我们执行以下操作:
-
重新启动
clock
并将前一个循环所花费的时间保存到dt
中。 -
在
m_GameTimeTotal
中跟踪已过总时间。 -
声明并初始化一个
float
来表示上一帧经过的秒数的一部分。 -
调用
input
。 -
调用
update
,传入经过的时间(dtAsSeconds
)。 -
调用
draw
。
所有这些看起来应该非常熟悉。新的地方是它被包裹在 run
函数中。
编写 input
函数定义的代码
正如我们之前解释的那样,input
函数的代码将放入它自己的文件中,因为它比构造函数或 run
函数更复杂。我们将使用 #include "Engine.h"
并在函数签名前加上 Engine::
前缀,以确保编译器了解我们的意图。
右键点击 Input.cpp
。最后,点击 input
函数。
添加以下代码:
void Engine::input()
{
Event event;
while (m_Window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
m_Window.close();
}
// Handle the player starting the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
m_Playing = true;
}
// Switch between Thomas and Bob
if (Keyboard::isKeyPressed(Keyboard::Q))
{
m_Character1 = !m_Character1;
}
// Switch between full and split-screen
if (Keyboard::isKeyPressed(Keyboard::E))
{
m_SplitScreen = !m_SplitScreen;
}
}
}
}
如同之前的几个项目,我们每帧都会检查 RenderWindow
事件队列。同样,我们也像之前做的那样,使用 if (Keyboard::isKeyPressed...
检测特定的键盘键。我们刚刚添加的代码中最相关的信息是这些键的功能:
-
如同往常,Esc 键会关闭窗口,并且游戏将退出。
-
Enter 键将
m_Playing
设置为 true,最终这将导致开始关卡。 -
Q 键在
m_Character1
的值之间交替切换为 true 和 false。此键仅在全屏模式下有效。它将在托马斯和鲍勃之间切换为主View
的中心。 -
E 键盘键在
m_SplitScreen
之间切换为 true 和 false。这将导致在全屏和分割屏幕视图之间切换。
到本章结束时,大部分键盘功能将完全可用。我们越来越接近能够运行我们的游戏引擎。接下来,让我们编写 update
函数。
编写 update
函数定义
正如我们之前解释的,这个函数的代码将放在它自己的文件中,因为它比构造函数或 run
函数更复杂。我们将使用 #include "Engine.h"
并在函数签名前加上 Engine::
前缀,以确保编译器了解我们的意图。
右键点击 Update.cpp
。最后,点击 update
函数。
将以下代码添加到 Update.cpp
文件中以实现 update
函数:
#include "Engine.h"
#include <SFML/Graphics.hpp>
#include <sstream>
using namespace sf;
void Engine::update(float dtAsSeconds)
{
if (m_Playing)
{
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
首先,注意 update
函数接收前一个帧所花费的时间作为参数。这当然对于 update
函数履行其角色至关重要。
在这个阶段,之前的代码没有实现任何可见的功能。它确实为我们未来章节所需的结构奠定了基础。它从 m_TimeRemaining
中减去前一个帧所花费的时间,并检查时间是否已用完。如果时间已用完,它将 m_NewLevelRequired
设置为 true
。所有这些代码都包含在一个只有当 m_Playing
为 true
时才执行的 if
语句中。这样做的原因是,如同之前的几个项目,我们不希望在游戏未开始时时间前进和对象更新。
随着项目的继续,我们将在此基础上构建代码。
编写 draw
函数定义
正如我们之前解释的,这个函数的代码将放在它自己的文件中,因为它比构造函数或 run
函数更复杂。我们将使用 #include "Engine.h"
并在函数签名前加上 Engine::
前缀,以确保编译器了解我们的意图。
右键点击 Draw.cpp
。最后点击 draw
函数。
将以下代码添加到 Draw.cpp
文件中以实现 draw
函数:
#include "Engine.h"
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
在前面的代码中,没有我们之前没有见过的内容。代码像往常一样从清除屏幕开始。在这个项目中,我们使用白色清除屏幕。新的地方在于不同的绘图选项是通过检查屏幕是否当前分割的条件来分隔的:
if (!m_SplitScreen)
{
}
else
{
}
如果屏幕没有分割,我们在背景View
(m_BGView
)中绘制背景精灵,然后切换到主全屏View
(m_MainView
)。请注意,目前我们并没有在m_MainView
中进行任何绘制。
另一方面,如果屏幕分割,则执行else
块中的代码,我们使用屏幕左侧的背景精灵绘制m_BGLeftView
,然后切换到m_LeftView
。
然后,仍然在else
块中,我们在屏幕右侧使用背景精灵绘制m_BGRightView
,然后切换到m_RightView
。
在我们刚才描述的if
else
结构之外,我们切换到m_HUDView
。在这个阶段,我们实际上并没有在m_HUDView
中绘制任何东西。
就像其他两个(input
、update
)三个最重要的函数一样,我们经常会回到draw
函数。我们将为游戏添加需要绘制的新元素。你会注意到,每次我们这样做时,我们都会在主、左手和右手部分添加代码。
让我们快速回顾一下Engine
类,然后我们可以启动它。
到目前为止的Engine
类
我们所做的是将原来在main
函数中的所有代码抽象到input
、update
和draw
函数中。这些函数的连续循环以及计时由run
函数处理。
考虑在 Visual Studio 中保留Input.cpp、Update.cpp和Draw.cpp标签页打开,可能按照以下截图所示进行组织:
我们将在整个项目过程中回顾每个这些函数,并添加更多代码。现在,我们已经有了Engine
类的基本结构和功能,我们可以在main
函数中创建它的实例,并看到它的实际效果。
编写主函数
让我们将项目创建时自动生成的TFL.cpp
文件重命名为Main.cpp
。在Main.cpp
中右键点击TFL
文件。这将是我们包含main
函数和实例化Engine
类代码的文件。
将以下代码添加到Main.cpp
中:
#include "Engine.h"
int main()
{
// Declare an instance of Engine
Engine engine;
// Start the engine VRRrrrrmmm
engine.run();
// Quit in the usual way when the engine is stopped
return 0;
}
我们所做的一切只是为Engine
类添加一个include
指令,声明一个Engine
实例,然后调用它的run
函数。直到玩家退出,执行返回到main
和return 0
语句之前,一切都将由Engine
类处理。
这很简单。现在,我们可以运行游戏并看到空白的背景,无论是全屏还是分屏,最终将包含所有动作。
这是到目前为止的全屏模式下的游戏,只显示背景:
现在,按一下E键。你将能够看到屏幕整洁地分成两半,准备进行分屏合作游戏:
摘要
在本章中,我们介绍了《托马斯迟到了》游戏,并为理解以及整个项目的代码结构奠定了基础。当然,在解决方案资源管理器中确实有很多文件,但如果我们理解了每个文件的目的,我们会发现整个项目的其余部分实现起来相当容易。
在下一章中,我们将学习两个更基础的 C++主题,继承和多态。我们还将通过构建三个类来代表两个可玩角色,开始将它们应用到实践中。
常见问题解答
这里有一个可能出现在你脑海中的问题:
Q) 我不完全理解代码文件的结构。我该怎么做?
A) 确实,抽象可能会使我们的代码结构变得不那么清晰,但实际的代码本身会变得容易得多。与我们在之前的项目中一样,将所有内容都塞进main
函数中,我们将代码拆分为Input.cpp
、Update.cpp
和Draw.cpp
。此外,随着我们的进行,我们将使用更多的类来将相关的代码分组在一起。再次研究《托马斯迟到了代码结构》部分,特别是那些图表。
第十六章:第十五章:高级 OOP – 继承和多态
在本章中,我们将通过查看稍微高级一些的概念继承和多态来进一步扩展我们对面向对象编程(OOP)的知识。然后我们将能够使用这些新知识来实现游戏中的星级角色,托马斯和鲍勃。以下是本章我们将涵盖的内容:
-
学习如何使用继承扩展和修改一个类
-
通过使用多态,将一个类的对象视为多种类型的类
-
了解抽象类以及设计永远不会实例化的类实际上是有用的
-
构建一个抽象的
PlayableCharacter
类 -
使用
Thomas
和Bob
类实现继承 -
将托马斯和鲍勃添加到游戏项目中
继承
我们已经看到,我们可以通过从 SFML 库的类中实例化对象来利用他人的辛勤工作。但这个面向对象编程(OOP)的事情甚至比这还要深入。
如果有一个类中包含大量有用的功能,但并不完全符合我们的需求,那会怎样?在这种情况下,我们可以从其他类中继承。正如其名,继承意味着我们可以利用其他人的类的所有功能和好处,包括封装,同时进一步细化或扩展代码以适应我们的特定情况。在这个项目中,我们将从 SFML 类中继承和扩展;我们也将使用我们自己的类这样做。
让我们看看一些使用继承的代码。
扩展一个类
考虑到所有这些,让我们看看一个示例类,看看我们如何扩展它,只是为了看看语法,作为第一步。
首先,我们定义一个要继承的类。这与我们创建其他任何类的方式没有区别。看看这个假设的Soldier
类声明:
class Soldier
{
private:
// How much damage can the soldier take
int m_Health;
int m_Armour;
int m_Range;
int m_ShotPower;
Public:
void setHealth(int h);
void setArmour(int a);
void setRange(int r);
void setShotPower(int p);
};
在之前的代码中,我们定义了一个Soldier
类。它有四个私有变量:m_Health
、m_Armour
、m_Range
和m_ShotPower
。它还有四个公共函数:setHealth
、setArmour
、setRange
和setShotPower
。我们不需要看到这些函数的定义;它们将简单地初始化它们名称所暗示的适当变量。
我们也可以想象,一个完全实现的Soldier
类会比这个更深入。它可能包含诸如shoot
、goProne
等函数。如果我们在一个 SFML 项目中实现Soldier
类,它可能有一个Sprite
对象,以及update
和getPostion
函数。
我们在这里提出的简单场景,如果我们想了解继承,是合适的。现在,让我们看看一些新的内容:从Soldier
类继承。看看下面的代码,特别是高亮部分:
class Sniper : public Soldier
{
public:
// A constructor specific to Sniper
Sniper::Sniper();
};
通过在Sniper
类声明中添加: public Soldier
,Sniper
从Soldier
继承。但这究竟意味着什么呢?Sniper
Soldier
。它拥有Soldier
的所有变量和函数。然而,继承不仅仅是这样。
还要注意,在之前的代码中,我们声明了一个 Sniper
构造函数。这个构造函数是 Sniper
独有的。我们不仅继承了 Soldier
,我们还拥有 Soldier
。Soldier
类的所有功能(定义)将由 Soldier
类处理,但 Sniper
构造函数的定义必须由 Sniper
类处理。
这里是假设的 Sniper
构造函数定义可能的样子:
// In Sniper.cpp
Sniper::Sniper()
{
setHealth(10);
setArmour(10);
setRange(1000);
setShotPower(100);
}
我们可以继续编写其他一些扩展 Soldier
类的类,比如 Commando
和 Infantryman
。每个类都会有完全相同的变量和函数,但每个类也可以有一个独特的构造函数,初始化那些适合特定类型 Soldier
的变量。Commando
可能具有非常高的 m_Health
和 m_ShotPower
,但 m_Range
非常小。Infantryman
可能介于 Commando
和 Sniper
之间,每个变量的值都处于中等水平。
就像面向对象编程(OOP)本身已经足够有用一样,我们现在可以模拟现实世界中的对象,包括它们的层次结构。我们可以通过子类化/扩展/从其他类继承来实现这一点。
我们可能想在这里学习的一个术语是,从其扩展的类是 super-class,从超级类继承的类是 sub-class。我们也可以说 parent 和 child 类。
小贴士
你可能会对继承提出这个问题:为什么?原因可能如下:我们可以一次编写通用的代码;在父类中,我们可以更新这些通用代码,并且从它继承的所有类也会得到更新。此外,子类只能使用公共和 protected 实例变量和函数。所以,如果设计得当,这也增强了封装的目标。
你说的是 protected
吗?是的。有一个用于类变量和函数的访问修饰符叫做 protected
修饰符:
-
Public
变量和函数可以被任何拥有该类实例的人访问和使用。 -
Private
变量和函数只能由类的内部代码访问/使用,不能直接从实例中访问。这对于封装和当我们需要访问/更改私有变量时是有好处的,因为我们可以提供公共的获取器和设置器函数(例如getSprite
)。如果我们扩展一个具有private
变量和函数的类,那么这个子类不能直接访问其父类的私有数据。 -
Protected
变量和函数几乎与私有相同。它们不能被类的实例直接访问/使用。然而,它们可以被声明在其中的任何扩展类的实例直接使用。所以,它们就像私有一样,除了对子类。
要完全理解 protected
变量和函数是什么以及它们如何有用,我们先看看另一个话题。然后,我们将看到它们在实际中的应用。
多态
多态性使我们能够编写不那么依赖于我们试图操作的类型的代码。这可以使我们的代码更清晰、更高效。多态性意味着多种形式。如果我们编写的对象可以代表多种类型的事物,那么我们可以利用这一点。
重要提示
但多态性对我们来说意味着什么?简化到最简单的定义,多态性意味着以下内容:任何子类都可以作为使用超类的代码的一部分。这意味着我们可以编写更简单、更容易理解、也更容易修改或更改的代码。此外,我们可以为超类编写代码,并依赖于这样一个事实:无论它被子类化多少次,在一定的参数范围内,代码仍然可以工作。
让我们讨论一个例子。
假设我们想使用多态性来帮助编写一个动物园管理游戏,在这个游戏中我们必须喂养和照顾动物的需求。我们可能需要一个像feed
这样的函数。我们也可能想将待喂养的动物的实例传递给feed
函数。
当然,动物园有很多动物,比如狮子、大象和三趾树懒。根据我们对 C++继承的新知识,编写一个Animal
类并让所有不同类型的动物从它继承下来是有意义的。
如果我们想编写一个函数(feed
),我们可以将Lion
、Elephant
和ThreeToedSloth
作为参数传递,这似乎意味着我们需要为每种Animal
类型编写一个feed
函数。然而,我们可以编写具有多态返回类型和参数的多态函数。看看以下假设的feed
函数的定义:
void feed(Animal& a)
{
a.decreaseHunger();
}
前面的函数有一个Animal
引用作为参数,这意味着任何从扩展Animal
的类中构建的对象都可以传递给它。
这意味着你今天可以编写代码,然后在下周、下个月或下一年创建另一个子类,而相同的函数和数据结构仍然可以工作。此外,我们可以在子类上强制实施一套规则,关于它们可以做什么以及如何做,以及它们如何实现。因此,一个阶段的好设计可以在其他阶段产生影响。
但我们真的会想要实例化一个实际的 Animal 吗?
抽象类 - 虚函数和纯虚函数
一个抽象类是一个不能实例化的类,因此不能被制作成对象。
小贴士
我们可能想在这里了解的一些术语是具体类。一个具体类是任何非抽象类。换句话说,我们迄今为止编写的所有类都一直是具体类,并且可以被实例化为可用的对象。
那这是代码永远不会被使用吗?但那就像支付建筑师设计你的房子然后从不建造它一样!
如果我们,或者一个类的设计者,想要强制用户在使用他们的类之前继承它,他们可以使一个类成为抽象类。如果这样做了,我们就不能从这个类中创建一个对象;因此,我们必须首先从它继承,然后从子类创建一个对象。
要做到这一点,我们可以定义一个纯虚函数而不提供任何定义。然后,这个函数必须在继承自它的任何类中被重写(重新定义)。
让我们看看一个例子;它会有所帮助。我们可以通过添加一个纯虚函数,例如抽象的Animal
类,它只能执行通用的makeNoise
动作,来使一个类成为抽象类:
Class Animal
private:
// Private stuff here
public:
void virtual makeNoise() = 0;
// More public stuff here
};
正如你所见,我们在函数声明之前添加了 C++关键字virtual
,并在其后添加了= 0
。现在,任何扩展/继承自Animal
的类都必须重写makeNoise
函数。这可能是合理的,因为不同类型的动物会发出非常不同的声音。我们可能假设扩展Animal
类的任何人足够聪明,会注意到Animal
类不能发出声音,并且他们将需要处理它,但如果没有注意到怎么办?重点是,通过定义一个纯虚函数,我们确保他们会,因为他们必须。
抽象类也很有用,因为有时我们想要一个可以作为多态类型的类,但我们又需要保证它永远不能作为一个对象使用。例如,Animal
本身并没有什么意义。我们不谈论动物;我们谈论动物的种类。我们不说,“哇,看那只可爱、蓬松、白色的动物!”或者,“昨天我们去宠物店买了一只动物和一张动物床。”这太抽象了。
因此,一个抽象类就像一个Worker
类,例如,我们可以扩展它来创建Miner
、Steelworker
、OfficeWorker
,当然还有Programmer
。但一个普通的Worker
到底做什么呢?我们为什么想要实例化一个呢?
答案是我们可能不想实例化一个,但我们可能想将其用作多态类型,这样我们就可以在函数之间传递多个Worker
子类,并且有可以存储所有类型工人的数据结构。
所有纯虚函数都必须由包含纯虚函数的父类扩展的任何类重写。这意味着抽象类可以提供一些在所有子类中都可用的公共功能。例如,Worker
类可能有m_AnnualSalary
、m_Productivity
和m_Age
成员变量。它也可能有一个getPayCheck
函数,这不是纯虚函数,在所有子类中都是相同的,但有一个doWork
函数,这是纯虚函数,必须被重写,因为所有不同类型的Worker
将会有不同的doWork
方式。
重要提示
顺便说一下,= 0
一直到最后。在当前的游戏项目中,我们将使用纯虚函数。
如果任何关于虚拟、纯虚或抽象的内容不清楚,使用它们可能是理解它们最好的方式。
构建 PlayableCharacter 类
现在我们已经了解了继承、多态和纯虚函数的基础,我们将把它们应用到实践中。我们将构建一个具有我们游戏中任何角色所需的大部分功能的 PlayableCharacter
类。它将有一个纯虚函数,称为 handleInput
。handleInput
函数在子类中需要相当不同,所以这样做是有意义的。
由于 PlayableCharacter
将有一个纯虚函数,它将是一个抽象类,并且不可能有它的对象。然后我们将构建 Thomas
和 Bob
类,这些类将继承自 PlayableCharacter
,实现纯虚函数的定义,并允许我们在游戏中实例化 Bob
和 Thomas
对象。不可能直接实例化一个 PlayableCharacter
实例,但我们不希望这样做,因为无论如何它都太抽象了。
编写 PlayableCharacter.h
正如创建类时的常规做法,我们将从包含成员变量和函数声明的头文件开始。新的地方在于,在这个类中,我们将声明一些 protected 成员变量。记住,受保护的变量可以在继承自具有受保护变量的类的类中使用,就像它们是公共的。
右键点击 PlayableCharacter.h
。最后,点击 PlayableCharacter
类。
我们将在三个部分中添加和讨论 PlayableCharacter.h
文件的内容。首先是 protected
部分,然后是 private
,最后是 public
。
将以下代码添加到 PlayableCharacter.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class PlayableCharacter
{
protected:
// Of course we will need a sprite
Sprite m_Sprite;
// How long does a jump last
float m_JumpDuration;
// Is character currently jumping or falling
bool m_IsJumping;
bool m_IsFalling;
// Which directions is the character currently moving in
bool m_LeftPressed;
bool m_RightPressed;
// How long has this jump lasted so far
float m_TimeThisJump;
// Has the player just initiated a jump
bool m_JustJumped = false;
// Private variables and functions come next
在我们刚刚编写的代码中,首先要注意的是所有变量都是 protected
。这意味着当我们从该类继承时,我们刚刚编写的所有变量都将对扩展它的那些类可访问。我们将使用 Thomas
和 Bob
类来扩展这个类。
小贴士
在本书的大多数上下文中,术语 继承自 和 扩展 几乎是同义的。然而,有时一个似乎比另一个更合适。
除了 protected
访问指定之外,之前的代码没有什么是新的或复杂的。然而,值得注意一些细节。如果我们这样做,随着我们前进,将很容易理解类的工作方式。所以,让我们逐个查看这些 protected
变量。
我们有我们相对可预测的 Sprite
,m_Sprite
。我们有一个名为 m_JumpDuration
的 float
变量,它将保存一个表示角色能够跳跃的时间的值。该值越大,角色能够跳得越远/越高。
接下来,我们有一个布尔值 m_IsJumping
,当角色在跳跃时为 true
,否则为 false
。这将确保角色在空中时不能跳跃。
m_IsFalling
变量与 m_IsJumping
有类似的作用。它将用来知道角色何时在坠落。
接下来,我们有两个布尔值,如果角色的左右键盘按钮当前被按下,它们将是 true
。这些相对依赖于角色(托马斯的 A 和 D,鲍勃的 Left 和 Right 光标键)。我们将如何响应这些布尔值将在 Thomas
和 Bob
类中看到。
m_TimeThisJump
浮点变量在 m_IsJumping
为 true
的每帧更新。然后我们可以找出何时达到 m_JumpDuration
。
最后一个 protected
变量是 m_JustJumped
布尔值。如果当前帧中启动了跳跃,它将是 true
。它将被用来知道何时播放跳跃音效。
接下来,将以下 private
变量添加到 PlayableCharacter.h
文件中:
private:
// What is the gravity
float m_Gravity;
// How fast is the character
float m_Speed = 400;
// Where is the player
Vector2f m_Position;
// Where are the characters various body parts?
FloatRect m_Feet;
FloatRect m_Head;
FloatRect m_Right;
FloatRect m_Left;
// And a texture
Texture m_Texture;
// All our public functions will come next
在之前的代码中,我们有一些有趣的 private
变量。请记住,这些变量将只直接对 PlayableCharacter
类中的代码可访问。Thomas
和 Bob
类将无法直接访问它们。
m_Gravity
变量将保存角色每秒下落的像素数。m_Speed
变量将保存角色每秒可以向左或向右移动的像素数。
Vector2f
,m_Position
变量是世界中(而非屏幕上)角色中心的位置。
接下来的四个 FloatRect
对象非常重要,需要讨论。在我们进行 Zombie Arena 游戏中的碰撞检测时,我们只是简单地检查两个 FloatRect
对象是否相交。每个 FloatRect
对象代表一个完整的角色、一个拾取物或一颗子弹。对于非矩形形状的对象(僵尸和玩家),这有点不准确。
在这个游戏中,我们需要更加精确。m_Feet
、m_Head
、m_Right
、m_Left
和 FloatRect
对象将保存角色身体不同部分的坐标。这些坐标将每帧更新。
通过这些坐标,我们能够精确地判断一个角色何时落在平台上,在跳跃中撞到头,或者与旁边的砖块摩擦肩膀。
最后,我们有一个 Texture
。Texture
是 private
的,因为它不是由 Thomas
或 Bob
类直接使用的。然而,正如我们所见,Sprite
是 protected
的,因为它被直接使用。
现在,将所有 public
函数添加到 PlayableCharacter.h
文件中。然后,我们将讨论它们:
public:
void spawn(Vector2f startPosition, float gravity);
// This is a pure virtual function
bool virtual handleInput() = 0;
// This class is now abstract and cannot be instantiated
// Where is the player
FloatRect getPosition();
// A rectangle representing the position
// of different parts of the sprite
FloatRect getFeet();
FloatRect getHead();
FloatRect getRight();
FloatRect getLeft();
// Send a copy of the sprite to main
Sprite getSprite();
// Make the character stand firm
void stopFalling(float position);
void stopRight(float position);
void stopLeft(float position);
void stopJump();
// Where is the center of the character
Vector2f getCenter();
// We will call this function once every frame
void update(float elapsedTime);
};// End of the class
让我们讨论一下我们刚刚添加的每个函数声明。这将使编写它们的定义更容易理解:
-
spawn
函数接收一个名为startPosition
的Vector2f
和一个名为gravity
的float
值。正如其名称所暗示的,startPosition
将是角色在关卡中开始的位置坐标,而gravity
将是角色下落的每秒像素数。 -
bool virtual handleInput() = 0
当然是我们纯虚函数。由于PlayableCharacter
有这个函数,任何扩展它的类,如果我们想实例化它,都必须为这个函数提供定义。因此,当我们在一分钟内编写PlayableCharacter
的所有函数定义时,我们不会为handleInput
提供定义。Thomas
和Bob
类中都需要有定义。 -
getPosition
函数返回一个FloatRect
对象,表示整个角色的位置。 -
getFeet
函数,以及getHead
、getRight
和getLeft
,返回一个FloatRect
对象,表示角色身体特定部分的位置。这正是我们进行详细碰撞检测所需要的。 -
getSprite
函数像往常一样,返回m_Sprite
的副本给调用代码。 -
stopFalling
、stopRight
、stopLeft
和stopJump
函数接收一个单个的float
值,该值将被函数用来重新定位角色并停止它穿过固体地砖行走或跳跃。 -
getCenter
函数返回一个Vector2f
对象给调用代码,让它知道角色的确切中心位置。这个值存储在m_Position
中。正如我们稍后将会看到的,它被Engine
类用来围绕适当的角色中心化适当的View
。 -
我们之前已经多次看到
update
函数,并且像往常一样,它接受一个float
参数,即当前帧所花费的秒数的分数。然而,这个update
函数需要比我们其他项目中的update
函数(from our other projects)做更多的工作。它需要处理跳跃以及更新表示角色头部、脚部和左右手的FloatRect
对象。
现在,我们可以编写所有函数的定义,当然,除了handleInput
函数。
编写PlayableCharacter.cpp
右键点击PlayableCharacter.cpp
。最后,点击PlayableCharacter
类的.cpp
文件。
我们将把代码分成几个部分来讨论。首先,添加包含指令和spawn
函数的定义:
#include "PlayableCharacter.h"
void PlayableCharacter::spawn(
Vector2f startPosition, float gravity)
{
// Place the player at the starting point
m_Position.x = startPosition.x;
m_Position.y = startPosition.y;
// Initialize the gravity
m_Gravity = gravity;
// Move the sprite in to position
m_Sprite.setPosition(m_Position);
}
spawn
函数使用传入的位置初始化m_Position
,并初始化m_Gravity
。代码的最后一行将m_Sprite
移动到其起始位置。
接下来,在之前的代码之后立即添加update
函数的定义:
void PlayableCharacter::update(float elapsedTime)
{
if (m_RightPressed)
{
m_Position.x += m_Speed * elapsedTime;
}
if (m_LeftPressed)
{
m_Position.x -= m_Speed * elapsedTime;
}
// Handle Jumping
if (m_IsJumping)
{
// Update how long the jump has been going
m_TimeThisJump += elapsedTime;
// Is the jump going upwards
if (m_TimeThisJump < m_JumpDuration)
{
// Move up at twice gravity
m_Position.y -= m_Gravity * 2 * elapsedTime;
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
}
// Apply gravity
if (m_IsFalling)
{
m_Position.y += m_Gravity * elapsedTime;
}
// Update the rect for all body parts
FloatRect r = getPosition();
// Feet
m_Feet.left = r.left + 3;
m_Feet.top = r.top + r.height - 1;
m_Feet.width = r.width - 6;
m_Feet.height = 1;
// Head
m_Head.left = r.left;
m_Head.top = r.top + (r.height * .3);
m_Head.width = r.width;
m_Head.height = 1;
// Right
m_Right.left = r.left + r.width - 2;
m_Right.top = r.top + r.height * .35;
m_Right.width = 1;
m_Right.height = r.height * .3;
// Left
m_Left.left = r.left;
m_Left.top = r.top + r.height * .5;
m_Left.width = 1;
m_Left.height = r.height * .3;
// Move the sprite into position
m_Sprite.setPosition(m_Position);
}
代码的前两部分检查m_RightPressed
或m_LeftPressed
是否为true
。如果其中任何一个为真,则使用与上一个项目相同的公式(经过时间乘以速度)更改m_Position
。
接下来,我们看到角色是否正在执行跳跃。我们可以从if(m_IsJumping)
中知道这一点。如果这个if
语句为真,代码将采取以下步骤:
-
使用
elapsedTime
更新m_TimeThisJump
。 -
检查
m_TimeThisJump
是否仍然小于m_JumpDuration
。如果是,它将m_Position
的 y 坐标通过 2 倍重力乘以经过的时间来改变。 -
当
m_TimeThisJump
不低于m_JumpDuration
时,else
子句会执行,将m_Falling
设置为true
。这样做的影响将在下面看到。同时,m_Jumping
被设置为false
。这防止了我们刚刚讨论的代码执行,因为if(m_IsJumping)
现在是false
。
if(m_IsFalling)
块在每一帧将 m_Position
向下移动。它是使用当前的 m_Gravity
值和经过的时间来移动的。
接下来的代码(剩余的大部分代码)更新角色的“身体部分”,相对于整个精灵的当前位置。请查看以下图表,以了解代码如何计算角色的虚拟头部、脚部和左右两侧的位置:
最后一行代码使用 setPosition
函数在所有 update
函数的可能性之后将精灵移动到其正确的位置。
现在,在之前的代码之后立即添加 getPosition
、getCenter
、getFeet
、getHead
、getLeft
、getRight
和 getSprite
函数的定义:
FloatRect PlayableCharacter::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Vector2f PlayableCharacter::getCenter()
{
return Vector2f(
m_Position.x + m_Sprite.getGlobalBounds().width / 2,
m_Position.y + m_Sprite.getGlobalBounds().height / 2
);
}
FloatRect PlayableCharacter::getFeet()
{
return m_Feet;
}
FloatRect PlayableCharacter::getHead()
{
return m_Head;
}
FloatRect PlayableCharacter::getLeft()
{
return m_Left;
}
FloatRect PlayableCharacter::getRight()
{
return m_Right;
}
Sprite PlayableCharacter::getSprite()
{
return m_Sprite;
}
getPosition
函数返回一个包裹整个精灵的 FloatRect
,而 getCenter
返回一个包含精灵中心的 Vector2f
。请注意,我们通过将精灵的高度和宽度除以 2 来动态地得到这个结果。这是因为托马斯和鲍勃的身高会有所不同。
getFeet
、getHead
、getLeft
和 getRight
函数返回代表我们在 update
函数中每帧更新的角色身体部分的 FloatRect
对象。我们将在下一章编写使用这些函数的碰撞检测代码。
getSprite
函数,像往常一样,返回 m_Sprite
的副本。
最后,对于 PlayableCharacter
类,在之前的代码之后立即添加 stopFalling
、stopRight
、stopLeft
和 stopJump
函数的定义。这样做:
void PlayableCharacter::stopFalling(float position)
{
m_Position.y = position - getPosition().height;
m_Sprite.setPosition(m_Position);
m_IsFalling = false;
}
void PlayableCharacter::stopRight(float position)
{
m_Position.x = position - m_Sprite.getGlobalBounds().width;
m_Sprite.setPosition(m_Position);
}
void PlayableCharacter::stopLeft(float position)
{
m_Position.x = position + m_Sprite.getGlobalBounds().width;
m_Sprite.setPosition(m_Position);
}
void PlayableCharacter::stopJump()
{
// Stop a jump early
m_IsJumping = false;
m_IsFalling = true;
}
之前每个函数都接收一个作为参数的值,用于重新定位精灵的顶部、底部、左侧或右侧。这些值具体是什么以及如何获得将是下一章的内容。之前每个函数也会重新定位精灵。
最后一个函数是 stopJump
函数,它也将用于碰撞检测。它设置必要的 m_IsJumping
和 m_IsFalling
值以结束跳跃。
构建托马斯和鲍勃类
现在,我们真正开始使用继承。我们将为托马斯和鲍勃各创建一个类。它们都将从我们刚刚编写的PlayableCharacter
类继承。它们将拥有PlayableCharacter
类的所有功能,包括直接访问其protected
变量。我们还将添加纯虚函数handleInput
的定义。你会注意到,Thomas
和Bob
的handleInput
函数将是不同的。
编写Thomas.h
右键点击Thomas.h
。最后,点击Thomas
类。
将以下代码添加到Thomas.h
类中:
#pragma once
#include "PlayableCharacter.h"
class Thomas : public PlayableCharacter
{
public:
// A constructor specific to Thomas
Thomas::Thomas();
// The overridden input handler for Thomas
bool virtual handleInput();
};
之前的代码非常简短且清晰。我们可以看到我们有一个构造函数,并且将要实现纯虚函数handleInput
。所以,我们现在就来做这件事。
编写Thomas.cpp
右键点击Thomas.cpp
。最后,点击Thomas
类的.cpp
文件。
将Thomas
构造函数添加到Thomas.cpp
文件中,如下所示:
#include "Thomas.h"
#include "TextureHolder.h"
Thomas::Thomas()
{
// Associate a texture with the sprite
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/thomas.png"));
m_JumpDuration = .45;
}
我们需要做的只是加载thomas.png
图形,并将跳跃的持续时间(m_JumpDuration
)设置为.45
(几乎半秒)。
按如下定义handleInput
函数:
// A virtual function
bool Thomas::handleInput()
{
m_JustJumped = false;
if (Keyboard::isKeyPressed(Keyboard::W))
{
// Start a jump if not already jumping
// but only if standing on a block (not falling)
if (!m_IsJumping && !m_IsFalling)
{
m_IsJumping = true;
m_TimeThisJump = 0;
m_JustJumped = true;
}
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
if (Keyboard::isKeyPressed(Keyboard::A))
{
m_LeftPressed = true;
}
else
{
m_LeftPressed = false;
}
if (Keyboard::isKeyPressed(Keyboard::D))
{
m_RightPressed = true;
}
else
{
m_RightPressed = false;
}
return m_JustJumped;
}
这段代码对你来说应该很熟悉。我们正在使用 SFML 的isKeyPressed
函数来检查W、A或D键是否被按下。
当按下W键时,玩家正在尝试跳跃。然后代码使用if(!m_IsJumping && !m_IsFalling)
来检查角色是否尚未跳跃,并且也没有在掉落。当这两个测试都为真时,m_IsJumping
被设置为true
,m_TimeThisJump
被设置为 0,m_JustJumped
也被设置为true
。
当前两个测试不评估为true
时,执行else
子句,并将m_Jumping
设置为false
,将m_IsFalling
设置为true
。
处理A和D键的按下就像设置m_LeftPressed
和/或m_RightPressed
为true
或false
一样简单。update
函数现在将能够处理角色的移动。
函数中的最后一行代码返回m_JustJumped
的值。这将让调用代码知道是否需要播放跳跃音效。
我们现在将编写Bob
类。它与Thomas
类几乎相同,除了它有不同的跳跃能力和不同的Texture
,并且使用不同的键盘键。
编写Bob.h
Bob
类在结构上与Thomas
类相同。它从PlayableCharacter
继承,有一个构造函数,并提供了handleInput
函数的定义。与Thomas
相比的不同之处在于我们以不同的方式初始化 Bob 的一些成员变量,并且在handleInput
函数中处理输入的方式也不同。让我们编写这个类并查看细节。
右键点击Bob.h
。最后,点击Bob
类。
将以下代码添加到Bob.h
文件中:
#pragma once
#include "PlayableCharacter.h"
class Bob : public PlayableCharacter
{
public:
// A constructor specific to Bob
Bob::Bob();
// The overriden input handler for Bob
bool virtual handleInput();
};
之前的代码与Thomas.h
文件相同,除了类名和因此构造函数名不同。
编写Bob.cpp
右键点击Bob.cpp
。最后,点击Bob
类的.cpp
文件。
将以下代码添加到Bob.cpp
文件的Bob
构造函数中。注意纹理是不同的(bob.png
),并且m_JumpDuration
被初始化为一个显著较小的值。现在 Bob 是他自己的独特个体:
#include "Bob.h"
#include "TextureHolder.h"
Bob::Bob()
{
// Associate a texture with the sprite
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/bob.png"));
m_JumpDuration = .25;
}
在Bob
构造函数之后立即添加handleInput
代码:
bool Bob::handleInput()
{
m_JustJumped = false;
if (Keyboard::isKeyPressed(Keyboard::Up))
{
// Start a jump if not already jumping
// but only if standing on a block (not falling)
if (!m_IsJumping && !m_IsFalling)
{
m_IsJumping = true;
m_TimeThisJump = 0;
m_JustJumped = true;
}
}
else
{
m_IsJumping = false;
m_IsFalling = true;
}
if (Keyboard::isKeyPressed(Keyboard::Left))
{
m_LeftPressed = true;
}
else
{
m_LeftPressed = false;
}
if (Keyboard::isKeyPressed(Keyboard::Right))
{
m_RightPressed = true;;
}
else
{
m_RightPressed = false;
}
return m_JustJumped;
}
注意,代码几乎与Thomas
类中的handleInput
函数中的代码相同。唯一的区别是我们响应不同的键(分别用左箭头键和右箭头键来控制左右移动,以及用上箭头键来进行跳跃)。
现在我们有一个PlayableCharacter
类,它被Bob
和Thomas
类扩展,我们可以向游戏中添加一个Bob
和Thomas
实例。
将游戏引擎更新为使用 Thomas 和 Bob
为了能够运行游戏并看到我们新的角色,我们必须声明它们的实例,调用它们的spawn
函数,每帧更新它们,并每帧绘制它们。现在让我们这样做。
更新 Engine.h 以添加 Bob 和 Thomas 的实例
打开Engine.h
文件并添加以下高亮显示的代码行:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
...
...
现在,我们有了Thomas
和Bob
的实例,它们都是PlayableCharacter
的派生类。
更新输入函数以控制 Thomas 和 Bob
现在,我们将添加控制两个角色的能力。这段代码将放在代码的输入部分。当然,对于这个项目,我们有一个专门的input
函数。打开Input.cpp
并添加以下高亮显示的代码:
void Engine::input()
{
Event event;
while (m_Window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Handle the player quitting
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
m_Window.close();
}
// Handle the player starting the game
if (Keyboard::isKeyPressed(Keyboard::Return))
{
m_Playing = true;
}
// Switch between Thomas and Bob
if (Keyboard::isKeyPressed(Keyboard::Q))
{
m_Character1 = !m_Character1;
}
// Switch between full and split-screen
if (Keyboard::isKeyPressed(Keyboard::E))
{
m_SplitScreen = !m_SplitScreen;
}
}
}
// Handle input specific to Thomas
if(m_Thomas.handleInput())
{
// Play a jump sound
}
// Handle input specific to Bob
if(m_Bob.handleInput())
{
// Play a jump sound
}
}
注意前面的代码是多么简单:所有功能都包含在Thomas
和Bob
类中。所有代码必须做的只是为每个Thomas
和Bob
类添加一个包含指令。然后,在input
函数中,代码只是调用m_Thomas
和m_Bob
上的纯虚handleInput
函数。我们之所以在每个调用中包裹一个if
语句,是因为它们根据是否刚刚成功启动了一个新的跳跃而返回true
或false
。我们将在第十七章中处理播放跳跃音效,声音空间化和 HUD。
更新更新函数以生成和更新可玩角色实例
这分为两个部分。首先,我们需要在新关卡开始时生成 Bob 和 Thomas,其次,我们需要每帧更新它们(通过调用它们的update
函数)。
生成 Thomas 和 Bob
随着项目的进展,我们需要在几个不同的地方调用我们的Thomas
和Bob
对象的spawn
函数。最明显的是,当新关卡开始时,我们需要生成两个角色。在下一章中,随着我们需要在关卡开始时执行的任务数量增加,我们将编写一个loadLevel
函数。现在,让我们在update
函数中调用m_Thomas
和m_Bob
的spawn
,如以下代码所示。添加以下代码,但请注意,它最终将被删除并替换:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// loadLevel() function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_TimeRemaining = 10;
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
之前的代码简单地调用spawn
并传入游戏世界中的一个位置,以及重力。代码被包裹在一个if
语句中,该语句检查是否需要新的关卡。生成代码将被移动到一个专门的loadLevel
函数中,但if
条件将是最终项目的一部分。此外,m_TimeRemaining
目前被设置为任意 10 秒。
现在,我们可以更新游戏循环中每一帧的实例。
每帧更新托马斯和鲍勃
接下来,我们将更新托马斯和鲍勃。我们只需要调用它们的update
函数,并传入这一帧所花费的时间。
添加以下高亮代码:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// LoadLevel function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
}
现在角色可以移动了,我们需要更新适当的View
对象,使它们围绕角色中心,并成为关注的焦点。当然,在我们游戏世界中没有一些对象之前,实际的移动感是无法实现的。
添加以下高亮代码:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// LoadLevel function soon
// Spawn Thomas and Bob
m_Thomas.spawn(Vector2f(0,0), GRAVITY);
m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
m_NewLevelRequired = false;
}
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
// Set the appropriate view around the appropriate character
if (m_SplitScreen)
{
m_LeftView.setCenter(m_Thomas.getCenter());
m_RightView.setCenter(m_Bob.getCenter());
}
else
{
// Centre full screen around appropriate character
if (m_Character1)
{
m_MainView.setCenter(m_Thomas.getCenter());
}
else
{
m_MainView.setCenter(m_Bob.getCenter());
}
}
}
之前的代码处理了两种可能的情况。首先,if(mSplitScreen)
条件将左侧视图定位在m_Thomas
周围,右侧视图定位在m_Bob
周围。当游戏处于全屏模式时,执行的else
子句检查m_Character1
是否为true
。如果是,则全屏视图(m_MainView
)将围绕托马斯居中,否则将围绕鲍勃居中。你可能记得,玩家可以使用E键切换分屏模式,使用Q键在全屏模式下在鲍勃和托马斯之间切换。我们在Engine
类的input
函数中实现了这一点,在第十二章中,分层视图和实现 HUD。
现在,我们可以将托马斯和鲍勃的图形绘制到屏幕上。
绘制鲍勃和托马斯
确保打开Draw.cpp
文件并添加以下高亮代码:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
注意,我们以全屏、左侧和右侧的方式绘制托马斯和鲍勃。还要注意我们在分屏模式下绘制角色的细微差别。当绘制屏幕的左侧时,我们改变绘制角色的顺序,并在鲍勃之后绘制托马斯。因此,托马斯将始终在左侧“位于上方”,鲍勃将始终在右侧“位于上方”。这是因为控制托马斯的玩家在左侧,而鲍勃在右侧。我们可能在Engine
类的input
函数中编码了这一点,在第十二章中,分层视图和实现 HUD。
现在,你可以运行游戏,看到托马斯和鲍勃位于屏幕中心,如下所示:
如果你按下Q键将焦点从托马斯切换到鲍勃,你会看到视图
进行轻微调整。如果你将任意一个角色向左或向右移动(托马斯使用A和D键,鲍勃使用箭头键),你会看到它们相对于彼此移动。
尝试按下E键在全屏和分屏之间切换。然后,再次尝试移动两个角色以查看效果。在下面的屏幕截图,你可以看到托马斯始终位于左侧窗口的中心,鲍勃始终位于右侧窗口的中心:
如果你让游戏运行足够长的时间,角色将在每 10 秒后在其原始位置重生。这是我们为完成的游戏所需功能的开端。这种行为是由m_TimeRemaining
低于 0 然后设置m_NewLevelRequired
变量为true
引起的。
还要注意,直到我们绘制出关卡细节,我们才能看到移动的完整效果。实际上,尽管看不见,两个角色都以每秒 300 像素的速度持续下落。由于摄像机每帧都围绕它们居中,且游戏世界中没有其他对象,所以我们看不到这种向下移动。
如果你想亲自看看,只需将m_Bob.spawn
的调用更改如下:
m_Bob.spawn(Vector2f(0,0), 0);
现在,鲍勃没有重力影响,托马斯将明显从他身边落下。这在上面的屏幕截图中显示:
在下一章中,我们将添加一些可玩关卡以供交互。
摘要
在本章中,我们学习了关于 C++的一些新概念,例如继承,这允许我们扩展一个类并获取其所有功能。我们还了解到我们可以声明变量为受保护的,这将使子类能够访问它们,但它们仍然对所有其他代码隐藏(封装)。我们还使用了纯虚函数,这使得类成为抽象的,意味着该类不能被实例化,因此必须从它继承或扩展。我们还介绍了多态的概念,但需要等到下一章才能在我们的游戏中使用它。
在下一章中,我们将向游戏中添加一些主要功能。到下一章结束时,托马斯和鲍勃将能够行走、跳跃和下落。他们甚至能够跳到彼此的头上,以及探索从文本文件中加载的一些关卡设计。
常见问题解答
Q) 我们学习了多态,但为什么我没有在游戏代码中注意到任何多态的东西呢?
A) 在下一章中,当我们编写一个以PlayerCharacter
为参数的函数时,我们将看到多态的实际应用。我们将看到如何将鲍勃和托马斯传递给这个新函数。对两者都适用。
第十七章:第十六章:构建可玩关卡和碰撞检测
这可能是这个项目中最有满足感的一章。原因是,到本章结束时,我们将有一个可玩的游戏。尽管仍有一些功能需要实现(声音、粒子效果、HUD 和着色器效果),但鲍勃和托马斯将能够跑步、跳跃和探索世界。此外,你只需在文本文件中创建平台和障碍物,就可以创建任何大小或复杂性的自己的关卡设计。
我们将通过以下主题来实现所有这些:
-
探索如何在文本文件中设计关卡
-
构建一个
LevelManager
类,它将从文本文件中加载关卡,将它们转换为游戏可以使用的数据,并跟踪关卡细节,如出生位置、当前关卡和允许的时间限制 -
更新游戏引擎以使用
LevelManager
-
编写一个多态函数来处理鲍勃和托马斯的碰撞检测
设计一些关卡
记得我们在第十四章中介绍的精灵图集,抽象和代码管理 – 更好地利用面向对象编程?这里它又出现了,用数字标注了我们将构建所有关卡的地块:
图片被放置在灰色背景上,这样我们可以更好地看到精灵图集的不同细节。棋盘格背景代表透明度级别。因此,除了编号 1 之外的所有地块都将至少透露出它们背后的背景。现在让我们来看看它们:
-
地块 0 是完全透明的,将用于填充没有其他地块的空隙。
-
地块 1 是托马斯和鲍勃将行走的平台。
-
地块 2 用于火焰地块,3 用于水域地块。
-
关于地块 4,你可能需要非常仔细地看才能看到它。它有一个白色的方块轮廓。这是关卡的目标,托马斯和鲍勃必须一起到达的地方。
在我们讨论设计关卡时,请记住这张图片。
我们将把这些地块编号的组合输入到文本文件中,以设计布局。以下是一个例子:
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000
1111111111000111111222222221111133111111111411
0000000000000000001222222221000133100000001110
0000000000000000001222222221000133100000000000
0000000000000000001222222221000133100000000000
0000000000000000001111111111000111100000000000
之前的代码转换为以下关卡布局:
注意,为了获取之前的截图,我不得不缩小视图
,并且图片已经被裁剪。实际的关卡开始看起来如下:
这些截图展示了两个要点。首先,你可以看到如何使用简单的免费文本编辑器,如 Windows 记事本或 Notepad ++,快速构建关卡设计。只需确保你使用等宽字体,这样所有的数字都是相同的大小。这使得设计关卡变得容易得多。
其次,这些截图展示了设计的游戏玩法方面。在关卡中从左到右,托马斯和鲍勃需要跳过一个小的洞,否则他们会掉入死亡(重生)。然后,他们需要穿越一大片火海。鲍勃无法跳过这么多方块。玩家需要合作找到解决方案。鲍勃清除火方块的唯一方法就是站在托马斯的头上并从那里跳起,如下面的截图所示:
然后,达到目标并进入下一级就相当简单了。
小贴士
我强烈建议你完成这一章,然后花些时间设计你自己的关卡。
我已经包含了一些关卡设计以供我们开始。它们位于我们在第十四章中添加到项目中的levels
文件夹中,抽象和代码管理 – 更好地利用面向对象编程。
那里有游戏的缩略视图,以及关卡设计的代码截图。代码截图可能比重现文本内容更有用。如果需要检查代码,只需打开levels
文件夹中的文件。
这就是代码的样子:
这是前述代码将生成的关卡布局:
这个关卡就是我第十四章中提到的“信仰跳跃”关卡:
以下是对游戏平台代码的突出显示,因为它们在接下来的缩略图中不是很清晰:
提供的设计很简单。游戏引擎能够处理非常大的设计,但我们有自由发挥想象力和构建一些长而具有挑战性的关卡。
当然,这些设计在没有学习如何加载它们并将文本转换为可玩关卡之前不会做任何事情。此外,在没有实现碰撞检测的情况下,玩家无法站在任何平台上。
首先,让我们处理加载关卡设计。
构建 LevelManager 类
要使我们的关卡设计工作,需要经过几个阶段的编码。
我们首先将编写LevelManager
头文件。这将使我们能够查看并讨论LevelManager
类中将包含的成员变量和函数。
接下来,我们将编写LevelManager.cpp
文件,其中将包含所有的函数定义。由于这是一个很长的文件,我们将将其分成几个部分进行编码和讨论。
一旦 LevelManager
类完成,我们将将其实例添加到游戏引擎(Engine
类)中。我们还将向 Engine
类添加一个新函数,loadLevel
,我们可以在需要新级别时从 update
函数中调用它。loadLevel
函数不仅将使用 LevelManager
实例加载适当的级别,还将处理诸如生成玩家角色和准备时钟等方面。
现在,让我们通过编写 LevelManager.h
文件来对 LevelManager
进行概述。
编写 LevelManager.h
右键点击 LevelManager.h
。最后,点击 LevelManager
类。
添加以下包含指令和私有变量,然后我们将讨论它们:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class LevelManager
{
private:
Vector2i m_LevelSize;
Vector2f m_StartPosition;
float m_TimeModifier = 1;
float m_BaseTimeLimit = 0;
int m_CurrentLevel = 0;
const int NUM_LEVELS = 4;
// public declarations go here
上述代码声明了一个 Vector2i
,m_LevelSize
,用于存储两个整数值,这两个值将包含当前地图包含的水平方向和垂直方向的瓦片数量。Vector2f
,m_StartPosition
包含 Bob 和 Thomas 应该在世界坐标中生成的坐标。请注意,这并不是与 m_LevelSize
单位相关的瓦片位置,而是在级别中的水平和垂直像素位置。
m_TimeModifier
成员变量是一个浮点类型变量,它将被用来乘以当前级别中可用的游戏时间。我们想要这样做的原因是,我们可以改变(减少)这个值,以便每次玩家尝试相同的级别时,都可以缩短可用的游戏时间。例如,如果玩家第一次尝试第 1 级时得到 60 秒,那么当然,60 乘以 1 就是 60。当玩家完成所有级别并第二次回到第 1 级时,m_TimeModifier
将减少了 10%。然后,当可用时间乘以 0.9 时,玩家可用的游戏时间将是 54 秒。这比原来少了 10%。游戏将逐渐变得更具挑战性。
m_BaseTimeLimit
浮点变量持有我们刚刚讨论的原始、未修改的时间限制。
我们可能可以猜测 m_CurrentLevel
将存储正在播放的当前级别编号。
int
和 NUM_LEVELS
常量将用于标记何时适宜再次回到第 1 级并减少 m_TimeModifier
的值。
现在,在之前添加的代码之后添加以下公共变量和函数声明:
public:
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
float getTimeLimit();
Vector2f getStartPosition();
int** nextLevel(VertexArray& rVaLevel);
Vector2i getLevelSize();
int getCurrentLevel();
};
在之前的代码中,有两个 int
类型的常量成员。TILE_SIZE
是一个有用的常量,提醒我们精灵图中每个瓦片的宽度为五十像素,高度为五十像素。VERTS_IN_QUAD
是一个有用的常量,可以使我们对 VertexArray
的操作更不容易出错。实际上,一个四边形有四个顶点。现在,我们不要忘记这一点。
getTimeLimit
、getStartPosition
、getLevelSize
和 getCurrentLevel
函数是简单的获取函数,它们返回我们在之前代码块中声明的私有成员变量的当前值。
值得更多讨论的函数是 nextLevel
。该函数接收一个 VertexArray
引用,就像我们在僵尸竞技场游戏中使用的那样。然后该函数可以操作 VertexArray
引用,所有更改都将反映在调用代码的 VertexArray
引用中。
nextLevel
函数返回一个指向指针,这意味着我们可以返回一个二维 int
值数组的第一个元素的地址。我们将构建一个二维 int
值数组,它将表示每个级别的布局。当然,这些 int
值将从级别设计文本文件中读取。
编写 LevelManager.cpp 文件
右键点击 LevelManager.cpp
。最后,点击 LevelManager
类的 .cpp
文件。
由于这是一个相当长的类,我们将将其分成六个部分来讨论。前五个将涵盖 nextLevel
函数,而第六个将涵盖其余的函数。
添加以下包含指令和 nextLevel
函数的第一个(五个中的第一个)部分:
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
#include "TextureHolder.h"
#include <sstream>
#include <fstream>
#include "LevelManager.h"
using namespace sf;
using namespace std;
int** LevelManager::nextLevel(VertexArray& rVaLevel)
{
m_LevelSize.x = 0;
m_LevelSize.y = 0;
// Get the next level
m_CurrentLevel++;
if (m_CurrentLevel > NUM_LEVELS)
{
m_CurrentLevel = 1;
m_TimeModifier -= .1f;
}
// Load the appropriate level from a text file
string levelToLoad;
switch (m_CurrentLevel)
{
case 1:
levelToLoad = "levels/level1.txt";
m_StartPosition.x = 100;
m_StartPosition.y = 100;
m_BaseTimeLimit = 30.0f;
break;
case 2:
levelToLoad = "levels/level2.txt";
m_StartPosition.x = 100;
m_StartPosition.y = 3600;
m_BaseTimeLimit = 100.0f;
break;
case 3:
levelToLoad = "levels/level3.txt";
m_StartPosition.x = 1250;
m_StartPosition.y = 0;
m_BaseTimeLimit = 30.0f;
break;
case 4:
levelToLoad = "levels/level4.txt";
m_StartPosition.x = 50;
m_StartPosition.y = 200;
m_BaseTimeLimit = 50.0f;
break;
}// End switch
在包含指令之后,代码将 m_LevelSize.x
和 m_LevelSize.y
变量初始化为零。
接下来,m_CurrentLevel
增加。随后的 if
语句检查 m_CurrentLevel
是否大于 NUM_LEVELS
。如果是,则将 m_CurrentLevel
设置回 1
,并将 m_TimeModifier
减少 0.1
以缩短所有级别的允许时间。
代码随后根据 m_CurrentLevel
保存的值进行切换。每个 case
语句初始化包含级别设计的文本文件名、托马斯和鲍勃的起始位置,以及 m_BaseTimeLimit
,这是所讨论级别的未修改时间限制。
小贴士
如果你设计自己的级别,请在此处添加一个 case
语句及其相应的值。还要编辑 LevelManager.h
文件中的 NUM_LEVELS
常量。
现在,添加 nextLevel
函数的第二部分,如下所示。将此代码立即添加到前面的代码之后。在我们添加代码的同时研究代码,这样我们就可以讨论它:
ifstream inputFile(levelToLoad);
string s;
// Count the number of rows in the file
while (getline(inputFile, s))
{
++m_LevelSize.y;
}
// Store the length of the rows
m_LevelSize.x = s.length();
在前面的(第二部分)代码中,我们声明了一个名为 inputFile
的 ifstream
对象,该对象打开到 levelToLoad
中包含的文件名的流。
代码通过 getLine
函数遍历文件的每一行,但并不记录其内容。它所做的只是通过增加 m_LevelSize.y
来计数行数。在 for
循环之后,使用 s.length
函数将级别的宽度保存到 m_LevelSize.x
中。这表明所有行的长度必须相同,否则我们会遇到麻烦。
在这个阶段,我们已经知道并保存了当前级别的长度和宽度在 m_LevelSize
中。
现在,添加 nextLevel
函数的第三部分,如下所示代码。将代码立即添加到前面的代码之下。在我们添加代码的同时研究代码,这样我们就可以讨论它:
// Go back to the start of the file
inputFile.clear();
inputFile.seekg(0, ios::beg);
// Prepare the 2D array to hold the int values from the file
int** arrayLevel = new int*[m_LevelSize.y];
for (int i = 0; i < m_LevelSize.y; ++i)
{
// Add a new array into each array element
arrayLevel[i] = new int[m_LevelSize.x];
}
首先,我们使用其clear
函数清除inputFile
。seekg
函数,使用0, ios::beg
参数调用,将文件光标的位置(下一个读取字符的位置)移动到文件开头。
接下来,我们声明一个指向指针的指针arrayLevel
。注意,这是使用new
关键字在自由存储/堆上完成的。一旦我们初始化了这个二维数组,我们就能将其地址返回给调用代码,并且它将一直持续到我们删除它或游戏关闭。
for
循环从 0 循环到m_LevelSize.y -1
。在循环的每次迭代中,它向堆中添加一个int
值的新数组,以匹配m_LevelSize.x
的值。现在我们有一个完美配置的(对于当前级别)二维数组。唯一的问题是它里面还没有任何内容。
现在,添加nextLevel
函数的第四部分,如下所示代码。立即在之前的代码之后添加此代码。在我们添加代码的同时研究代码,这样我们就可以讨论它:
// Loop through the file and store all
// the values in the 2d array
string row;
int y = 0;
while (inputFile >> row)
{
for (int x = 0; x < row.length(); x++) {
const char val = row[x];
arrayLevel[y][x] = atoi(&val);
}
y++;
}
// Close the file
inputFile.close();
首先,代码初始化一个名为row
的string
,它将一次只保存一层设计的一行。我们还声明并初始化一个名为y
的int
,它将帮助我们计数行数。
while
循环会重复执行,直到inputFile
超过最后一行。在while
循环内部,有一个for
循环,它会遍历当前行的每个字符,并将其存储在二维数组arrayLevel
中。注意,我们使用arrayLevel[y][x]=
访问二维数组的右侧元素。atoi
函数将char val
转换为int
。这是必需的,因为我们有一个用于int
的二维数组,而不是用于char
的。
现在,让我们添加nextLevel
函数的第五部分,如下所示。立即在之前的代码之后添加此代码。在我们添加代码的同时研究代码,这样我们就可以讨论它:
// What type of primitive are we using?
rVaLevel.setPrimitiveType(Quads);
// Set the size of the vertex array
rVaLevel.resize(m_LevelSize.x *
m_LevelSize.y * VERTS_IN_QUAD);
// Start at the beginning of the vertex array
int currentVertex = 0;
for (int x = 0; x < m_LevelSize.x; x++)
{
for (int y = 0; y < m_LevelSize.y; y++)
{
// Position each vertex in the current quad
rVaLevel[currentVertex + 0].position =
Vector2f(x * TILE_SIZE,
y * TILE_SIZE);
rVaLevel[currentVertex + 1].position =
Vector2f((x * TILE_SIZE) + TILE_SIZE,
y * TILE_SIZE);
rVaLevel[currentVertex + 2].position =
Vector2f((x * TILE_SIZE) + TILE_SIZE,
(y * TILE_SIZE) + TILE_SIZE);
rVaLevel[currentVertex + 3].position =
Vector2f((x * TILE_SIZE),
(y * TILE_SIZE) + TILE_SIZE);
// Which tile from the sprite sheet should we use
int verticalOffset = arrayLevel[y][x] * TILE_SIZE;
rVaLevel[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVaLevel[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVaLevel[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
rVaLevel[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
// Position ready for the next four vertices
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return arrayLevel;
} // End of nextLevel function
虽然这是五个部分中最长的代码段(我们将nextLevel
分成了两部分),但它也是最直接的。这是因为我们在 Zombie Arena 项目中看到了非常相似的代码。
上述代码的过程是,嵌套的for
循环从零开始循环到当前层的宽度和高度。对于数组中的每个位置,将四个顶点放入VertexArray
,并从精灵图中分配四个纹理坐标。顶点和纹理坐标的位置是使用currentVertex
变量、TILE SIZE
和VERTS_IN_QUAD
常量计算得出的。在内层for
循环的每次循环结束时,currentVertex
增加VERTS_IN_QUAD
,从而顺利移动到下一个瓦片。
重要提示
关于VertexArray
需要记住的重要一点是,它是通过引用传递给nextLevel
的。因此,VertexArray
将在调用代码中可用。我们将从Engine
类中的代码调用nextLevel
。
一旦调用此函数,Engine
类将有一个 VertexArray
来图形化表示关卡,以及一个二维 int
值数组,作为关卡中所有平台和障碍物的数值表示。
LevelManager
类的其余函数都是简单的获取函数,但请花时间熟悉每个函数返回哪个私有值。添加 LevelManager
类的其余函数,如下所示:
Vector2i LevelManager::getLevelSize()
{
return m_LevelSize;
}
int LevelManager::getCurrentLevel()
{
return m_CurrentLevel;
}
float LevelManager::getTimeLimit()
{
return m_BaseTimeLimit * m_TimeModifier;
}
Vector2f LevelManager::getStartPosition()
{
return m_StartPosition;
}
现在 LevelManager
类已经完成,我们可以继续使用它。我们将在 Engine
类中编写另一个函数来实现这一点。
编写 loadLevel 函数
为了清晰起见,此函数是 Engine
类的一部分,尽管它将大部分工作委托给其他函数,包括我们刚刚构建的 LevelManager
类的函数。
首先,让我们将新函数的声明以及一些其他新代码添加到 Engine.h
文件中。打开 Engine.h
文件,并添加 Engine.h
文件缩略快照中显示的突出显示的代码行,如下所示:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
// The force pushing the characters down
const int GRAVITY = 300;
// A regular RenderWindow
RenderWindow m_Window;
// The main Views
View m_MainView;
View m_LeftView;
View m_RightView;
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
// Start in full screen mode
bool m_SplitScreen = false;
// How much time is left in the current level
float m_TimeRemaining = 10;
Time m_GameTimeTotal;
// Is it time for a new/first level?
bool m_NewLevelRequired = true;
// The vertex array for the level tiles
VertexArray m_VALevel;
// The 2d array with the map for the level
// A pointer to a pointer
int** m_ArrayLevel = NULL;
// Texture for the level tiles
Texture m_TextureTiles;
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
// Load a new level
void loadLevel();
public:
// The Engine constructor
Engine();
...
...
...
这就是我们可以在前面的代码中看到的内容:
-
我们包含了
LevelManager.h
文件。 -
我们添加了一个名为
m_LM
的LevelManager
实例。 -
我们添加了一个名为
m_VALevel
的VertexArray
。 -
我们添加了一个指向指向整数的指针,它将持有从
nextLevel
返回的二维数组。 -
我们为精灵图集添加了一个新的
Texture
对象。 -
我们添加了我们将要编写的
loadLevel
函数的声明。
右键单击 LoadLevel.cpp
。最后,单击 loadLevel
函数。
将 loadLevel
函数的代码添加到 LoadLevel.cpp
文件中。然后,我们可以讨论它:
#include "Engine.h"
void Engine::loadLevel()
{
m_Playing = false;
// Delete the previously allocated memory
for (int i = 0; i < m_LM.getLevelSize().y; ++i)
{
delete[] m_ArrayLevel[i];
}
delete[] m_ArrayLevel;
// Load the next 2d array with the map for the level
// And repopulate the vertex array as well
m_ArrayLevel = m_LM.nextLevel(m_VALevel);
// How long is this new time limit
m_TimeRemaining = m_LM.getTimeLimit();
// Spawn Thomas and Bob
m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY);
m_Bob.spawn(m_LM.getStartPosition(), GRAVITY);
// Make sure this code isn't run again
m_NewLevelRequired = false;
}
首先,我们将 m_Playing
设置为 false
以停止 update
函数的部分执行。接下来,我们遍历 m_ArrayLevel
中的所有水平数组并将它们删除。在 for
循环之后,我们删除 m_ArrayLevel
本身。
m_ArrayLevel = m_LM.nextLevel(m_VALevel)
调用 nextLevel
并准备 VertexArray
m_VALevel
以及称为 m_ArrayLevel
的二维数组。关卡已设置并准备就绪。
通过调用 getTimeLimit
初始化 m_TimeRemaining
,并使用 spawn
函数生成 Thomas 和 Bob,同时使用 getStartPosition
返回的值。
最后,将 m_NewLevelRequired
设置为 false
。正如我们将在几页后看到的那样,将 m_NewLevelRequired
设置为 true
会导致调用 loadLevel
。我们只想运行这个函数一次。
更新引擎
打开 Engine.cpp
文件,并在 Engine 构造函数的末尾添加以下突出显示的代码来加载精灵图集纹理:
Engine::Engine()
{
// Get the screen resolution and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the full screen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Initialize the split-screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
m_Window.close();
}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
// Load the texture for the background vertex array
m_TextureTiles = TextureHolder::GetTexture(
"graphics/tiles_sheet.png");
}
在前面的代码中,我们只是将精灵图集加载到 m_TextureTiles
。
打开 Update.cpp
文件,进行以下突出显示的更改和添加:
void Engine::update(float dtAsSeconds)
{
if (m_NewLevelRequired)
{
// These calls to spawn will be moved to a new
// loadLevel function soon
// Spawn Thomas and Bob
//m_Thomas.spawn(Vector2f(0,0), GRAVITY);
//m_Bob.spawn(Vector2f(100, 0), GRAVITY);
// Make sure spawn is called only once
//m_TimeRemaining = 10;
//m_NewLevelRequired = false;
// Load a level
loadLevel();
}
实际上,我们应该删除而不是注释掉我们不再使用的行。我之所以这样展示,是为了使更改清晰。在之前的if
语句中,应该只有调用loadLevel
的代码。
最后,在我们能够看到本章到目前为止所做工作的结果之前,打开Draw.cpp
文件,并添加以下突出显示的修改以绘制表示一个级别的顶点数组:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
// Show everything we have just drawn
m_Window.display();
}
注意,我们需要为所有屏幕选项(全屏、左屏和右屏)绘制VertexArray
。
现在,你可以运行游戏了。然而,不幸的是,Thomas 和 Bob 会直接穿过我们精心设计的所有平台。因此,我们无法尝试通过关卡并击败时钟。
碰撞检测
我们将使用矩形交集和 SFML 的intersects
函数来处理碰撞检测。在这个项目中,我们将把碰撞检测代码抽象成一个单独的函数。正如我们已经看到的,Thomas 和 Bob 有多个矩形(m_Head
、m_Feet
、m_Left
和m_Right
),我们需要检查这些矩形是否发生碰撞。
编写detectCollisions
函数
为了清晰起见,这个函数是Engine
类的一部分。打开Engine.h
文件,并添加一个名为detectCollisions
的函数声明。以下代码片段中已突出显示:
// Private functions for internal use only
void input();
void update(float dtAsSeconds);
void draw();
// Load a new level
void loadLevel();
bool detectCollisions(PlayableCharacter& character);
public:
// The Engine constructor
Engine();
注意,从签名中可以看出,detectCollision
函数接受一个作为PlayerCharacter
对象的泛型参数。正如我们所知,PlayerCharacter
是抽象的,永远不能实例化。然而,我们确实通过Thomas
和Bob
类从它继承。我们将能够将m_Thomas
或m_Bob
传递给detectCollisions
。
右键点击DetectCollisions.cpp
。最后,点击detectCollisions
函数。
将以下代码添加到DetectCollisions.cpp
中。请注意,这仅仅是这个函数的第一部分:
#include "Engine.h"
bool Engine::detectCollisions(PlayableCharacter& character)
{
bool reachedGoal = false;
// Make a rect for all his parts
FloatRect detectionZone = character.getPosition();
// Make a FloatRect to test each block
FloatRect block;
block.width = TILE_SIZE;
block.height = TILE_SIZE;
// Build a zone around thomas to detect collisions
int startX = (int)(detectionZone.left / TILE_SIZE) - 1;
int startY = (int)(detectionZone.top / TILE_SIZE) - 1;
int endX = (int)(detectionZone.left / TILE_SIZE) + 2;
// Thomas is quite tall so check a few tiles vertically
int endY = (int)(detectionZone.top / TILE_SIZE) + 3;
// Make sure we don't test positions lower than zero
// Or higher than the end of the array
if (startX < 0)startX = 0;
if (startY < 0)startY = 0;
if (endX >= m_LM.getLevelSize().x)
endX = m_LM.getLevelSize().x;
if (endY >= m_LM.getLevelSize().y)
endY = m_LM.getLevelSize().y;
我们首先声明一个名为reachedGoal
的布尔值。这是detectCollisions
函数返回给调用代码的值。它被初始化为false
。
接下来,我们声明一个名为detectionZone
的FloatRect
对象,并用代表整个角色精灵矩形相同的矩形初始化它。请注意,我们实际上不会与这个矩形进行交集测试。之后,我们声明另一个名为block
的FloatRect
。我们将block
初始化为一个 50x50 的游戏单位矩形。我们很快就会看到block
的使用。
接下来,我们将看看如何使用detectionZone
。我们通过在detectionZone
周围扩展几个方块的区域来初始化四个int
变量,startX
、startY
、endX
和endY
。在接下来的四个if
语句中,我们检查不可能尝试在不存在的小块上进行碰撞检测。我们将通过确保我们永远不会检查小于零或大于getLevelSize().x
或.y
返回值的坐标位置来实现这一点。
所有之前的代码所做的是创建一个用于碰撞检测的区域。在距离角色数百或数千像素的方块上进行碰撞检测是没有意义的。此外,如果我们尝试在数组位置不存在的地方(小于零或大于getLevelSize()...
)进行碰撞检测,游戏将会崩溃。
接下来,添加以下代码,用于处理玩家从关卡中掉落的情况:
// Has the character fallen out of the map?
FloatRect level(0, 0,
m_LM.getLevelSize().x * TILE_SIZE,
m_LM.getLevelSize().y * TILE_SIZE);
if (!character.getPosition().intersects(level))
{
// respawn the character
character.spawn(m_LM.getStartPosition(), GRAVITY);
}
为了使角色停止下落,它必须与平台发生碰撞。因此,如果玩家移动出地图(那里没有平台),他们将会持续下落。之前的代码检查角色是否没有与FloatRect
、level
相交。如果没有,那么它已经掉出关卡,spawn
函数将其送回起点。
添加以下相当长的代码块,然后我们将解释它的功能:
// Loop through all the local blocks
for (int x = startX; x < endX; x++)
{
for (int y = startY; y < endY; y++)
{
// Initialize the starting position of the current block
block.left = x * TILE_SIZE;
block.top = y * TILE_SIZE;
// Has character been burnt or drowned?
// Use head as this allows him to sink a bit
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getHead().intersects(block))
{
character.spawn(m_LM.getStartPosition(), GRAVITY);
// Which sound should be played?
if (m_ArrayLevel[y][x] == 2)// Fire, ouch!
{
// Play a sound
}
else // Water
{
// Play a sound
}
}
}
// Is character colliding with a regular block
if (m_ArrayLevel[y][x] == 1)
{
if (character.getRight().intersects(block))
{
character.stopRight(block.left);
}
else if (character.getLeft().intersects(block))
{
character.stopLeft(block.left);
}
if (character.getFeet().intersects(block))
{
character.stopFalling(block.top);
}
else if (character.getHead().intersects(block))
{
character.stopJump();
}
}
// More collision detection here once we have
// learned about particle effects
// Has the character reached the goal?
if (m_ArrayLevel[y][x] == 4)
{
// Character has reached the goal
reachedGoal = true;
}
}
}
之前的代码使用相同的技巧做了三件事。它遍历startX
、endX
和startY
、endY
之间的所有值。对于每次遍历,它都会检查并执行以下操作:
-
角色是否被烧伤或淹死?
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
确定当前检查的位置是否是火或水方块。如果角色的头部与这些方块之一相交,玩家将被重生。我们还编写了一个空的if
/else
块,为下一章添加声音做准备。 -
角色是否触碰到普通方块?
code if (m_ArrayLevel[y][x] == 1)
确定当前检查的位置是否持有普通方块。如果它与代表角色各个身体部位的矩形之一相交,则调用相关函数(stopRight
、stopLeft
、stopFalling
或stopJump
)。传递给每个函数的值以及函数如何使用这些值来重新定位角色相当微妙。虽然理解代码不需要仔细检查这些值,但我们可能想查看传递的值,然后参考上一章中PlayableCharacter
类的适当函数。这将帮助你真正理解正在发生的事情。 -
角色是否触碰到目标方块?这是通过
if (m_ArrayLevel[y][x] == 4)
来确定的。我们只需要将reachedGoal
设置为true
。Engine
类的update
函数将跟踪两个角色(托马斯和鲍勃)是否同时达到目标。我们将在下一分钟内将此代码写入update
函数。
将以下行代码添加到detectCollisions
函数中:
// All done, return, whether or
// not a new level might be required
return reachedGoal;
}
上一行代码返回reachedGoal
布尔值,以便调用代码可以跟踪并适当地响应,如果两个角色同时达到目标。
我们现在需要做的只是对每个字符、每帧调用一次detectCollision
函数。在Update.cpp
文件中if(m_Playing)
代码块内添加以下高亮代码:
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Detect collisions and see if characters
// have reached the goal tile
// The second part of the if condition is only executed
// when thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
}
else
{
// Run bobs collision detection
detectCollisions(m_Bob);
}
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
之前的代码调用了detectCollision
函数,并检查鲍勃和托马斯是否同时达到目标。如果他们做到了,那么下一级将通过将m_NewLevelRequired
设置为true
来准备。
你可以运行游戏并在平台上行走。你可以达到目标并开始新的一级。此外,对于第一次,跳跃按钮(W或向上箭头)将生效。
如果你达到目标,则将加载下一级。如果你达到最后一关的目标,则第一级将以 10%减少的时间限制加载。当然,由于我们还没有构建 HUD,所以没有时间或当前级别的视觉反馈。我们将在下一章中这样做。
然而,许多关卡需要托马斯和鲍勃作为团队一起工作。更具体地说,托马斯和鲍勃需要能够爬到彼此的头上。
更多碰撞检测
在Update.cpp
文件中添加了之前的代码之后,在if (m_Playing)
部分之后添加以下代码:
if (m_Playing)
{
// Update Thomas
m_Thomas.update(dtAsSeconds);
// Update Bob
m_Bob.update(dtAsSeconds);
// Detect collisions and see if characters
// have reached the goal tile
// The second part of the if condition is only executed
// when thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
}
else
{
// Run bobs collision detection
detectCollisions(m_Bob);
}
// Let bob and thomas jump on each others heads
if (m_Bob.getFeet().intersects(m_Thomas.getHead()))
{
m_Bob.stopFalling(m_Thomas.getHead().top);
}
else if (m_Thomas.getFeet().intersects(m_Bob.getHead()))
{
m_Thomas.stopFalling(m_Bob.getHead().top);
}
// Count down the time the player has left
m_TimeRemaining -= dtAsSeconds;
// Have Thomas and Bob run out of time?
if (m_TimeRemaining <= 0)
{
m_NewLevelRequired = true;
}
}// End if playing
你可以再次运行游戏并站在托马斯和鲍勃的头上,以到达之前无法到达的难以到达的地方:
摘要
本章中有很多代码。我们学习了如何从文件中读取并将文本字符串转换为char
值,然后转换为 int值
。一旦我们有了int
值的二维数组,我们就能填充一个VertexArray
实例来在屏幕上显示关卡。然后我们使用相同的int
值的二维数组来实现碰撞检测。我们使用了矩形交集,就像我们在 Zombie Arena 项目中做的那样,不过这次,为了提高精度,我们给每个角色分配了四个碰撞区域——分别代表他们的头部、脚部、左侧和右侧。
现在游戏已经完全可玩,我们需要在屏幕上表示游戏状态(得分和时间)。在下一章中,我们将实现 HUD,以及一些比我们迄今为止使用的更高级的声音效果。
第十八章:第十七章:声音空间化和 HUD
在本章中,我们将添加所有音效和 HUD。我们已经在之前的两个项目中这样做过,但这次我们将有所不同。我们将探讨声音空间化的概念以及 SFML 如何使这个原本复杂的概念变得简单易行。此外,我们还将构建一个 HUD 类来封装将信息绘制到屏幕上的代码。
我们将按照以下顺序完成这些任务。
-
什么是空间化?
-
SFML 如何处理空间化
-
构建 SoundManager 类
-
部署发射器
-
使用 SoundManager 类
-
构建一个
HUD
类 -
使用
HUD
类
什么是空间化?
空间化是将某物与它所包含的空间或其中的空间相关联的行为。在我们的日常生活中,自然世界中的所有事物默认都是空间化的。如果一辆摩托车从左到右呼啸而过,我们将会听到声音从一侧的微弱到另一侧的响亮。当它经过时,它会在另一只耳朵中变得更加突出,然后再逐渐消失在远处。如果我们某天早上醒来,发现世界不再空间化,那将会非常奇怪。
如果我们能让我们的视频游戏更接近现实世界,我们的玩家可以更加沉浸其中。如果玩家能在远处听到僵尸微弱的声音,而他们的非人类尖叫随着他们越来越近而变得越来越大,我们的僵尸游戏会更有趣。
很可能很明显,空间化的数学将是复杂的。我们如何根据玩家(声音的听者)到发出声音的对象(发射器)的距离和方向来计算特定扬声器中给定声音的响度?
幸运的是,SFML 为我们处理了所有复杂的过程。我们只需要熟悉一些技术术语,然后我们就可以开始使用 SFML 来空间化我们的音效。
发射器、衰减和听者
为了给 SFML 提供它完成工作所需的信息,我们需要了解一些信息。我们需要知道声音在我们的游戏世界中是从哪里发出的。这个声音的来源被称为发射器。在游戏中,发射器可以是僵尸、车辆,或者在我们当前的项目中,是一个火砖。我们已经一直在跟踪我们游戏中对象的位置,因此向 SFML 提供发射器的位置将非常直接。
我们需要关注的下一个因素是衰减。衰减是波衰减的速度。你可以简化这个陈述,并使其具体到声音,即衰减是声音减少音量的速度。这从技术上讲并不准确,但对于本章和我们的游戏来说,这是一个足够好的描述。
我们需要考虑的最后一个因素是监听器。当 SFML 空间化声音时,它是相对于什么进行空间化的;游戏的“耳朵”在哪里?在大多数游戏中,合乎逻辑的做法是使用玩家角色。在我们的游戏中,我们将使用托马斯(我们的玩家角色)。
使用 SFML 处理空间化
SFML 有几个函数允许我们处理发射器、衰减和监听器。让我们假设地看看它们,然后我们将编写一些代码来真正地将空间化声音添加到我们的项目中。
我们可以设置一个准备播放的声音效果,就像我们经常做的那样,如下所示:
// Declare SoundBuffer in the usual way
SoundBuffer zombieBuffer;
// Declare a Sound object as-per-usual
Sound zombieSound;
// Load the sound from a file like we have done so often
zombieBuffer.loadFromFile("sound/zombie_growl.wav");
// Associate the Sound object with the Buffer
zombieSound.setBuffer(zombieBuffer);
我们可以使用以下代码中显示的 setPosition
函数来设置发射器的位置:
// Set the horizontal and vertical positions of the emitter
// In this case the emitter is a zombie
// In the Zombie Arena project we could have used
// getPosition().x and getPosition().y
// These values are arbitrary
float x = 500;
float y = 500;
zombieSound.setPosition(x, y, 0.0f);
如前一段代码的注释中所建议的,我们如何确切地获取发射器的坐标可能会取决于游戏类型。正如前一段代码所示,在僵尸竞技场项目中,这将会非常简单。在我们这个项目中设置位置时,我们将面临一些挑战。
我们可以设置衰减级别如下:
zombieSound.setAttenuation(15);
实际的衰减级别可能有点模糊。我们希望玩家获得的效果可能与基于衰减的准确科学公式所使用的减少距离上的音量不同。获得正确的衰减级别通常是通过实验来实现的。衰减级别越高,音量降低到静音的速度就越快。
此外,我们可能还想设置一个围绕发射器的区域,其中音量不会衰减。如果我们认为这个特性在某个范围之外不合适,或者如果我们有多个声音源并且不想“过度”使用这个特性,我们可能会这样做。为此,我们可以使用如下所示的 setMinimumDistance
函数:
zombieSound.setMinDistance(150);
使用上一行代码,衰减只有在监听器距离发射器 150 像素/单位时才会计算。
SFML 库中还有一些其他有用的函数,包括 setLoop
函数。当将 true
作为参数传入时,此函数会告诉 SFML 无限次地播放声音,如下面的代码所示:
zombieSound.setLoop(true);
声音将继续播放,直到我们使用以下代码结束它:
zombieSound.stop();
有时,我们想知道声音的状态(播放或停止)。我们可以通过以下代码中的 getStatus
函数来实现,如下所示:
if (zombieSound.getStatus() == Sound::Status::Stopped)
{
// The sound is NOT playing
// Take whatever action here
}
if (zombieSound.getStatus() == Sound::Status::Playing)
{
// The sound IS playing
// Take whatever action here
}
使用 SFML 进行声音空间化时,我们需要覆盖的最后一个方面是监听器。监听器在哪里?我们可以使用以下代码设置监听器的位置:
// Where is the listener?
// How we get the values of x and y varies depending upon the game
// In the Zombie Arena game or the Thomas Was Late game
// We can use getPosition()
Listener::setPosition(m_Thomas.getPosition().x,
m_Thomas.getPosition().y, 0.0f);
之前的代码将使所有声音相对于该位置播放。这正是我们需要的,用于远处火砖或即将到来的僵尸的咆哮声,但对于像跳跃这样的常规声音效果,这是一个问题。我们可以开始处理玩家的位置发射器,但 SFML 为我们简化了这些事情。每次我们想要播放一个“正常”的声音时,我们只需像以下代码所示调用setRelativeToListener
,然后以我们迄今为止相同的方式播放声音。以下是我们可能播放的“正常”非空间化跳跃声音效果的方式:
jumpSound.setRelativeToListener(true);
jumpSound.play();
我们需要做的只是在我们播放任何空间化声音之前再次调用Listener::setPosition
。
我们现在拥有丰富的 SFML 声音函数,我们准备好真正制作一些空间化噪音了。
构建 SoundManager 类
你可能还记得上一个项目中,所有的声音代码占据了相当多的代码行。现在,考虑到空间化,它还将变得更长。为了保持我们的代码可管理,我们将编写一个类来管理所有正在播放的声音效果。此外,为了帮助我们进行空间化,我们还将向Engine
类添加一个函数,但我们将稍后在本章中讨论这一点。
编写 SoundManager.h
让我们开始编写和检查头文件。
右键点击SoundManager.h
。最后,点击SoundManager
类。
添加并检查以下代码:
#pragma once
#include <SFML/Audio.hpp>
using namespace sf;
class SoundManager
{
private:
// The buffers
SoundBuffer m_FireBuffer;
SoundBuffer m_FallInFireBuffer;
SoundBuffer m_FallInWaterBuffer;
SoundBuffer m_JumpBuffer;
SoundBuffer m_ReachGoalBuffer;
// The Sounds
Sound m_Fire1Sound;
Sound m_Fire2Sound;
Sound m_Fire3Sound;
Sound m_FallInFireSound;
Sound m_FallInWaterSound;
Sound m_JumpSound;
Sound m_ReachGoalSound;
// Which sound should we use next, fire 1, 2 or 3
int m_NextSound = 1;
public:
SoundManager();
void playFire(Vector2f emitterLocation,
Vector2f listenerLocation);
void playFallInFire();
void playFallInWater();
void playJump();
void playReachGoal();
};
我们刚刚添加的代码中没有什么复杂的。有五个SoundBuffer
对象和八个Sound
对象。其中三个Sound
对象将播放相同的SoundBuffer
。这解释了为什么Sound
/SoundBuffer
对象的数量不同。我们这样做是为了能够同时播放多个具有不同空间化参数的咆哮声音效果。
注意m_NextSound
变量,它将帮助我们跟踪下一次应该使用这些同时播放的声音中的哪一个。
有一个构造函数SoundManager
,我们将设置所有声音效果,并且有五个函数将播放声音效果。其中四个函数简单地播放“正常”声音效果,它们的代码会更简单。
其中一个函数playFire
将处理空间化声音效果,并且会稍微深入一些。注意playFire
函数的参数。它接收一个Vector2f
,这是发射器的位置,以及第二个Vector2f
,这是听者的位置。
编写 SoundManager.cpp 文件
现在,我们可以编写函数定义。构造函数和playFire
函数有大量的代码,所以我们将单独查看它们。其他函数都很短小精悍,所以我们将一次性处理它们。
右键点击SoundManager.cpp
。最后,点击SoundManager
类的.cpp
文件。
编写构造函数
将以下代码添加到SoundManager.cpp
的包含指令和构造函数中:
#include "SoundManager.h"
#include <SFML/Audio.hpp>
using namespace sf;
SoundManager::SoundManager()
{
// Load the sound in to the buffers
m_FireBuffer.loadFromFile("sound/fire1.wav");
m_FallInFireBuffer.loadFromFile("sound/fallinfire.wav");
m_FallInWaterBuffer.loadFromFile("sound/fallinwater.wav");
m_JumpBuffer.loadFromFile("sound/jump.wav");
m_ReachGoalBuffer.loadFromFile("sound/reachgoal.wav");
// Associate the sounds with the buffers
m_Fire1Sound.setBuffer(m_FireBuffer);
m_Fire2Sound.setBuffer(m_FireBuffer);
m_Fire3Sound.setBuffer(m_FireBuffer);
m_FallInFireSound.setBuffer(m_FallInFireBuffer);
m_FallInWaterSound.setBuffer(m_FallInWaterBuffer);
m_JumpSound.setBuffer(m_JumpBuffer);
m_ReachGoalSound.setBuffer(m_ReachGoalBuffer);
// When the player is 50 pixels away sound is full volume
float minDistance = 150;
// The sound reduces steadily as the player moves further away
float attenuation = 15;
// Set all the attenuation levels
m_Fire1Sound.setAttenuation(attenuation);
m_Fire2Sound.setAttenuation(attenuation);
m_Fire3Sound.setAttenuation(attenuation);
// Set all the minimum distance levels
m_Fire1Sound.setMinDistance(minDistance);
m_Fire2Sound.setMinDistance(minDistance);
m_Fire3Sound.setMinDistance(minDistance);
// Loop all the fire sounds
// when they are played
m_Fire1Sound.setLoop(true);
m_Fire2Sound.setLoop(true);
m_Fire3Sound.setLoop(true);
}
在前面的代码中,我们将五个声音文件加载到五个SoundBuffer
对象中。接下来,我们将八个Sound
对象与一个SoundBuffer
对象关联起来。请注意,m_Fire1Sound
、m_Fire2Sound
和m_Fire3Sound
都将从同一个SoundBuffer
,即m_FireBuffer
中播放。
接下来,我们设置了三个火焰声音的衰减和最小距离。
小贴士
150
和15
的值是通过实验得到的。一旦游戏开始运行,建议通过更改这些值来实验,看看(或者更确切地说,听听)它们之间的差异。
最后,对于构造函数,我们在每个与火焰相关的Sound
对象上使用setLoop
函数。现在,当我们调用play
时,它们将连续播放。
编写playFire
函数
添加playFire
函数如下。然后,我们可以讨论它:
void SoundManager::playFire(
Vector2f emitterLocation, Vector2f listenerLocation)
{
// Where is the listener? Thomas.
Listener::setPosition(listenerLocation.x,
listenerLocation.y, 0.0f);
switch(m_NextSound)
{
case 1:
// Locate/move the source of the sound
m_Fire1Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire1Sound.getStatus() == Sound::Status::Stopped)
{
// Play the sound, if its not already
m_Fire1Sound.play();
}
break;
case 2:
// Do the same as previous for the second sound
m_Fire2Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire2Sound.getStatus() == Sound::Status::Stopped)
{
m_Fire2Sound.play();
}
break;
case 3:
// Do the same as previous for the third sound
m_Fire3Sound.setPosition(emitterLocation.x,
emitterLocation.y, 0.0f);
if (m_Fire3Sound.getStatus() == Sound::Status::Stopped)
{
m_Fire3Sound.play();
}
break;
}
// Increment to the next fire sound
m_NextSound++;
// Go back to 1 when the third sound has been started
if (m_NextSound > 3)
{
m_NextSound = 1;
}
}
我们首先调用Listener::setPosition
并根据传入的参数Vector2f
设置听者的位置。
接下来,代码进入一个switch
块,测试m_NextSound
的值。每个case
语句都做完全相同的事情,但针对m_Fire1Sound
、m_Fire2Sound
或m_Fire3Sound
。
在每个case
块中,我们使用setPosition
函数和传入的参数设置发射器的位置。每个case
块中的代码的下一部分检查声音是否当前已停止,如果是,则播放声音。很快,我们将看到如何得到传递给此函数的发射器和听者的位置。
playFire
函数的最后部分增加m_NextSound
,并确保它只能等于 1、2 或 3,这是switch
块所要求的。
编写 SoundManager 的其他函数
添加以下四个简单函数:
void SoundManager::playFallInFire()
{
m_FallInFireSound.setRelativeToListener(true);
m_FallInFireSound.play();
}
void SoundManager::playFallInWater()
{
m_FallInWaterSound.setRelativeToListener(true);
m_FallInWaterSound.play();
}
void SoundManager::playJump()
{
m_JumpSound.setRelativeToListener(true);
m_JumpSound.play();
}
void SoundManager::playReachGoal()
{
m_ReachGoalSound.setRelativeToListener(true);
m_ReachGoalSound.play();
}
playFallInFire
、playFallInWater
和playReachGoal
函数只做两件事。首先,它们各自调用setRelativeToListener
,以便声音效果不是空间化的,使声音效果“正常”,而不是方向性的,然后它们在适当的Sound
对象上调用play
。
这样,SoundManager
类就完成了。现在,我们可以在Engine
类中使用它。
将 SoundManager 添加到游戏引擎中
打开Engine.h
文件,并添加一个新的SoundManager
类实例,如下所示的高亮代码:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
// Create a SoundManager
SoundManager m_SM;
const int TILE_SIZE = 50;
const int VERTS_IN_QUAD = 4;
到目前为止,我们可以使用m_SM
调用各种play...
函数。不幸的是,为了管理发射器(火焰瓷砖)的位置,我们还需要做一些额外的工作。
填充声音发射器
打开Engine.h
文件,并添加一个populateEmitters
函数的新原型和一个新的 STL vector
,包含Vector2f
对象:
...
...
...
// Run will call all the private functions
bool detectCollisions(PlayableCharacter& character);
// Make a vector of the best places to emit sounds from
void populateEmitters(vector <Vector2f>& vSoundEmitters,
int** arrayLevel);
// A vector of Vector2f for the fire emitter locations
vector <Vector2f> m_FireEmitters;
public:
...
...
...
populateEmitters
函数接受一个Vector2f
对象的vector
作为参数,以及一个指向int
指针的指针(一个二维数组)。vector
将保存每个发射器在关卡中的位置。数组是包含关卡布局的二维数组。
编写 populateEmitters 函数
populateEmitters
函数的职责是遍历arrayLevel
数组中的所有元素,并决定将发射器放置在哪里。它将结果存储在m_FireEmitters
中。
右键点击PopulateEmitters.cpp
。最后,点击populateEmitters
。
将代码完整地添加进去。确保你在添加代码的同时仔细研究代码,这样我们就可以讨论它:
#include "Engine.h"
using namespace sf;
using namespace std;
void Engine::populateEmitters(
vector <Vector2f>& vSoundEmitters,
int** arrayLevel)
{
// Make sure the vector is empty
vSoundEmitters.empty();
// Keep track of the previous emitter
// so we don't make too many
FloatRect previousEmitter;
// Search for fire in the level
for (int x = 0; x < (int)m_LM.getLevelSize().x; x++)
{
for (int y = 0; y < (int)m_LM.getLevelSize().y; y++)
{
if (arrayLevel[y][x] == 2)// fire is present
{
// Skip over any fire tiles too
// near a previous emitter
if (!FloatRect(x * TILE_SIZE,
y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE).intersects(previousEmitter))
{
// Add the coordinates of this water block
vSoundEmitters.push_back(
Vector2f(x * TILE_SIZE, y * TILE_SIZE));
// Make a rectangle 6 blocks x 6 blocks,
// so we don't make any more emitters
// too close to this one
previousEmitter.left = x * TILE_SIZE;
previousEmitter.top = y * TILE_SIZE;
previousEmitter.width = TILE_SIZE * 6;
previousEmitter.height = TILE_SIZE * 6;
}
}
}
}
return;
}
一些代码可能乍一看很复杂。理解我们用来选择发射器位置的技巧会使它变得简单。在我们的关卡中,有大量的火砖块。例如,在一个关卡中,有超过 30 块火砖聚集在一起。代码确保在给定的矩形内只有一个发射器。这个矩形存储在previousEmitter
中,大小为 300 像素乘 300 像素(TILE_SIZE * 6
)。
代码设置了一个嵌套的for
循环,遍历arrayLevel
,寻找火砖。当找到火砖时,它会确保它不与previousEmitter
相交。只有在确保不相交后,它才会使用pushBack
函数向vSoundEmitters
添加另一个发射器。之后,它还会更新previousEmitter
以避免出现大量声音发射器的聚集。
让我们制造一些噪音。
播放声音
打开LoadLevel.cpp
文件,并添加对新的populateEmitters
函数的调用,如下面的代码所示:
void Engine::loadLevel()
{
m_Playing = false;
// Delete the previously allocated memory
for (int i = 0; i < m_LM.getLevelSize().y; ++i)
{
delete[] m_ArrayLevel[i];
}
delete[] m_ArrayLevel;
// Load the next 2d array with the map for the level
// And repopulate the vertex array as well
m_ArrayLevel = m_LM.nextLevel(m_VALevel);
// Prepare the sound emitters
populateEmitters(m_FireEmitters, m_ArrayLevel);
// How long is this new time limit
m_TimeRemaining = m_LM.getTimeLimit();
// Spawn Thomas and Bob
m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY);
m_Bob.spawn(m_LM.getStartPosition(), GRAVITY);
// Make sure this code isn't run again
m_NewLevelRequired = false;
}
首先要添加的声音是跳跃声音。我们记得键盘处理代码位于Bob
和Thomas
类中的纯虚函数内,并且当成功发起跳跃时,handleInput
函数返回true
。
打开Input.cpp
文件,并添加以下高亮代码行以在托马斯或鲍勃成功开始跳跃时播放跳跃声音:
// Handle input specific to Thomas
if (m_Thomas.handleInput())
{
// Play a jump sound
m_SM.playJump();
}
// Handle input specific to Bob
if (m_Bob.handleInput())
{
// Play a jump sound
m_SM.playJump();
}
打开Update.cpp
文件,并添加以下高亮代码行以在托马斯和鲍勃同时达到当前关卡目标时播放成功声音:
// Detect collisions and see if characters have reached the goal tile
// The second part of the if condition is only executed
// when Thomas is touching the home tile
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
{
// New level required
m_NewLevelRequired = true;
// Play the reach goal sound
m_SM.playReachGoal();
}
else
{
// Run Bobs collision detection
detectCollisions(m_Bob);
}
此外,在Update.cpp
文件中,我们将添加代码来遍历m_FireEmitters
向量,并决定何时调用SoundManager
类的playFire
函数。
仔细观察新高亮代码周围的小部分上下文。在正确的位置添加此代码是至关重要的:
}// End if playing
// Check if a fire sound needs to be played
vector<Vector2f>::iterator it;
// Iterate through the vector of Vector2f objects
for (it = m_FireEmitters.begin(); it != m_FireEmitters.end(); it++)
{
// Where is this emitter?
// Store the location in pos
float posX = (*it).x;
float posY = (*it).y;
// is the emitter near the player?
// Make a 500 pixel rectangle around the emitter
FloatRect localRect(posX - 250, posY - 250, 500, 500);
// Is the player inside localRect?
if (m_Thomas.getPosition().intersects(localRect))
{
// Play the sound and pass in the location as well
m_SM.playFire(Vector2f(posX, posY), m_Thomas.getCenter());
}
}
// Set the appropriate view around the appropriate character
上述代码有点类似于声音的碰撞检测。每当托马斯进入围绕火发射器的一个 500 像素乘 500 像素的矩形内时,就会调用playFire
函数,并将发射器和托马斯的坐标传递给它。playFire
函数完成剩余的工作并播放一个空间化的循环声音效果。
打开 DetectCollisions.cpp
文件,找到合适的位置,并添加以下突出显示的代码。这两行突出显示的代码会在任意一个角色掉入水或火焰方块时触发声音效果:
// Has character been burnt or drowned?
// Use head as this allows him to sink a bit
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getHead().intersects(block))
{
character.spawn(m_LM.getStartPosition(), GRAVITY);
// Which sound should be played?
if (m_ArrayLevel[y][x] == 2)// Fire, ouch!
{
// Play a sound
m_SM.playFallInFire();
}
else // Water
{
// Play a sound
m_SM.playFallInWater();
}
}
}
现在玩游戏将允许你在靠近火焰方块时听到所有声音,包括酷炫的空间化效果。
实现 HUD 类
HUD 非常简单,与僵尸竞技场项目相比并没有什么不同。我们将要做的是将所有代码封装在一个新的 HUD
类中。如果我们将所有 Font
、Text
和其他变量声明为这个新类的成员,我们就可以在构造函数中初始化它们,并为它们的值提供获取函数。这将使 Engine
类免于大量的声明和初始化。
编写 HUD.h
首先,我们将使用所有成员变量和函数声明来编写 HUD.h
文件。右键点击 HUD.h
。最后,点击 HUD
类。
将以下代码添加到 HUD.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Hud
{
private:
Font m_Font;
Text m_StartText;
Text m_TimeText;
Text m_LevelText;
public:
Hud();
Text getMessage();
Text getLevel();
Text getTime();
void setLevel(String text);
void setTime(String text);
};
在前面的代码中,我们添加了一个 Font
实例和三个 Text
实例。Text
对象将用于显示提示用户开始、剩余时间和当前关卡编号的消息。
公共函数更有趣。首先,是构造函数,大部分代码将在这里编写。构造函数将初始化 Font
和 Text
对象,并将它们相对于当前屏幕分辨率定位在屏幕上。
三个获取函数 getMessage
、getLevel
和 getTime
将返回一个 Text
对象给调用代码,以便它可以将它们绘制到屏幕上。
setLevel
和 setTime
函数将分别用于更新显示在 m_LevelText
和 m_TimeText
中的文本。
现在,我们可以编写我们刚刚声明的所有函数的定义。
编写 HUD.cpp 文件
右键点击 HUD.cpp
。最后,点击 HUD
类的 .cpp
文件。
添加包含指令和以下代码。然后,我们将讨论它:
#include "Hud.h"
Hud::Hud()
{
Vector2u resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
// Load the font
m_Font.loadFromFile("fonts/Roboto-Light.ttf");
// when Paused
m_StartText.setFont(m_Font);
m_StartText.setCharacterSize(100);
m_StartText.setFillColor(Color::White);
m_StartText.setString("Press Enter when ready!");
// Position the text
FloatRect textRect = m_StartText.getLocalBounds();
m_StartText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
m_StartText.setPosition(
resolution.x / 2.0f, resolution.y / 2.0f);
// Time
m_TimeText.setFont(m_Font);
m_TimeText.setCharacterSize(75);
m_TimeText.setFillColor(Color::White);
m_TimeText.setPosition(resolution.x - 150, 0);
m_TimeText.setString("------");
// Level
m_LevelText.setFont(m_Font);
m_LevelText.setCharacterSize(75);
m_LevelText.setFillColor(Color::White);
m_LevelText.setPosition(25, 0);
m_LevelText.setString("1");
}
首先,我们将水平和垂直分辨率存储在一个名为 resolution
的 Vector2u
中。接下来,我们从添加回的 fonts
目录中加载字体,这是在 第十四章,抽象和代码管理 – 更好地使用面向对象编程 中提到的。
接下来的四行代码设置了 m_StartText
的字体、颜色、大小和文本。在这段代码之后,它捕获了包裹 m_StartText
的矩形的尺寸,并执行计算以确定如何在屏幕上将其居中。如果你想要对这个代码部分的更详细解释,请参考 第三章,C++ 字符串和 SFML 时间 – 玩家输入和 HUD。
在构造函数的最后两个代码块中,设置了 m_TimeText
和 m_LevelText
的字体、文本大小、颜色、位置和实际文本。稍后我们将看到,这两个 Text
对象将通过两个设置函数进行更新,当需要时。
立即将以下获取和设置函数添加到我们刚刚添加的代码下方:
Text Hud::getMessage()
{
return m_StartText;
}
Text Hud::getLevel()
{
return m_LevelText;
}
Text Hud::getTime()
{
return m_TimeText;
}
void Hud::setLevel(String text)
{
m_LevelText.setString(text);
}
void Hud::setTime(String text)
{
m_TimeText.setString(text);
}
上一段代码中的前三个函数简单地返回适当的 Text
对象,即 m_StartText
、m_LevelText
或 m_TimeText
。我们将在将 HUD 绘制到屏幕上时不久使用这些函数。最后的两个函数,setLevel
和 setTime
,使用 setString
函数来更新适当的 Text
对象,该对象将使用从 Engine
类的 update
函数传递的值,每 500 帧。
完成所有这些后,我们可以在我们的游戏引擎中使用 HUD 类。
使用 HUD 类
打开 Engine.h
文件,为我们的新类添加一个包含语句,声明一个新 HUD
类的实例,并声明并初始化两个新的成员变量,这两个变量将跟踪我们更新 HUD 的频率。正如我们在前面的项目中所学到的,我们不需要每帧都更新 HUD。
将以下突出显示的代码添加到 Engine.h
:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
#include "HUD.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
// A class to manage all the levels
LevelManager m_LM;
// Create a SoundManager
SoundManager m_SM;
// The Hud
Hud m_Hud;
int m_FramesSinceLastHUDUpdate = 0;
int m_TargetFramesPerHUDUpdate = 500;
const int TILE_SIZE = 50;
接下来,我们需要向 Engine
类的 update
函数中添加一些代码。打开 Update.cpp
文件,并添加以下突出显示的代码以每 500 帧更新一次 HUD:
// Set the appropriate view around the appropriate character
if (m_SplitScreen)
{
m_LeftView.setCenter(m_Thomas.getCenter());
m_RightView.setCenter(m_Bob.getCenter());
}
else
{
// Centre full screen around appropriate character
if (m_Character1)
{
m_MainView.setCenter(m_Thomas.getCenter());
}
else
{
m_MainView.setCenter(m_Bob.getCenter());
}
}
// Time to update the HUD?
// Increment the number of frames since
// the last HUD calculation
m_FramesSinceLastHUDUpdate++;
// Update the HUD every m_TargetFramesPerHUDUpdate frames
if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate)
{
// Update game HUD text
stringstream ssTime;
stringstream ssLevel;
// Update the time text
ssTime << (int)m_TimeRemaining;
m_Hud.setTime(ssTime.str());
// Update the level text
ssLevel << "Level:" << m_LM.getCurrentLevel();
m_Hud.setLevel(ssLevel.str());
m_FramesSinceLastHUDUpdate = 0;
}
}// End of update function
在上述代码中,m_FramesSinceLastUpdate
在每帧都会增加。当 m_FramesSinceLastUpdate
超过 m_TargetFramesPerHUDUpdate
时,执行进入 if
块。在 if
块内部,我们使用 stringstream
对象来更新我们的 Text
,就像我们在前面的项目中做的那样。在这个项目中,我们使用 HUD
类,因此我们通过传递当前 Text
对象需要设置的值来调用 setTime
和 setLevel
函数。
if
块中的最后一步是将 m_FramesSinceLastUpdate
设置回零,以便它可以开始计算下一次更新。
最后,打开 Draw.cpp
文件,并添加以下突出显示的代码以每帧绘制 HUD:
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}// End of draw
上述代码通过使用 HUD 类的获取函数来绘制 HUD。请注意,调用绘制提示玩家开始游戏的信息的代码仅在游戏当前未在播放时使用 (!m_Playing)
。
运行游戏并玩几个关卡,以查看时间逐渐减少,关卡逐渐增加。当你再次回到关卡 1 时,请注意你比之前少了 10% 的时间。
摘要
在本章中,我们探讨了声音空间化。我们的 "Thomas Was Late" 游戏现在不仅完全可玩,我们还添加了方向性音效和一个简单但信息丰富的 HUD。我们也可以轻松添加新关卡。到目前为止,我们可以称之为完成了。
希望能增加一些亮点。在下一章中,我们将探讨两个游戏概念。首先,我们将研究粒子系统,这是如何处理爆炸或其他特殊效果的方法。为了实现这一点,我们需要学习更多关于 C++的知识。因此,多重继承这一主题将被引入。
之后,当我们学习 OpenGL 和可编程图形管线时,我们将为游戏增添最后的点缀。那时,我们将能够尝试接触GLSL语言,它允许我们编写直接在 GPU 上执行代码,从而创建一些特殊效果。
第十九章:第十八章:粒子系统和着色器
在本章中,我们将探讨粒子系统是什么,然后将其编码到我们的游戏中。我们将探讨 OpenGL 着色器的基础,并看看用另一种语言(GLSL)编写代码,该代码可以直接在图形卡上运行,可以产生可能无法实现的平滑图形效果。像往常一样,我们也将使用我们的新技能和知识来增强当前项目。
在本章中,我们将涵盖以下主题:
-
构建粒子系统
-
OpenGL 着色器和 GLSL
-
在《托马斯迟到》游戏中使用着色器
构建粒子系统
在我们开始编码之前,看看我们究竟要实现什么是非常有帮助的。
查看以下图表:
之前的插图是粒子效果在普通背景上的截图。我们将在我们的游戏中使用这个效果。每当玩家死亡时,我们将生成这些效果之一。
我们实现这种效果的方式如下:
-
首先,我们在一个选定的像素位置生成 1,000 个点(粒子),一个叠在另一个上面。
-
游戏的每一帧都会将 1,000 个粒子以预定的但随机的速度和角度向外移动。
-
重复第二步两秒钟,然后让粒子消失。
我们将使用 VertexArray
来绘制所有点,并使用 Point
的原始类型来在视觉上表示每个粒子。此外,我们将从 SFML 的 Drawable
类继承,这样我们的粒子系统就可以负责绘制自己。
编码粒子类
Particle
类将是一个简单的类,它只代表一千个粒子中的一个。让我们开始编码。
编码 Particle.h
右键点击 Particle.h
。最后,点击 Particle
类。
将以下代码添加到 Particle.h
文件中:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Particle
{
private:
Vector2f m_Position;
Vector2f m_Velocity;
public:
Particle(Vector2f direction);
void update(float dt);
void setPosition(Vector2f position);
Vector2f getPosition();
};
在前面的代码中,我们有两个 Vector2f
对象。一个将代表粒子的水平和垂直坐标,而另一个将代表水平和垂直速度。
重要提示
当你在多个方向上有变化率(速度)时,组合的值也定义了一个方向。这个 Vector2f
被称为 m_Velocity
。
我们还有几个公共函数。首先是构造函数。它接受一个 Vector2f
并使用它来让系统知道这个粒子将有什么方向/速度。这意味着系统,而不是粒子本身,将选择速度。
接下来是 update
函数,它接受上一帧所花费的时间。我们将使用这个时间来精确地移动粒子。
最后两个函数,setPosition
和 getPosition
,用于移动粒子的位置和分别找出其位置。
当我们编码这些函数时,它们都将完全有意义。
编码 Particle.cpp
文件
右键点击 Particle.cpp
。最后,点击 Particle
类的 .cpp
文件。
将以下代码添加到 Particle.cpp
:
#include "Particle.h"
Particle::Particle(Vector2f direction)
{
// Determine the direction
m_Velocity.x = direction.x;
m_Velocity.y = direction.y;
}
void Particle::update(float dtAsSeconds)
{
// Move the particle
m_Position += m_Velocity * dtAsSeconds;
}
void Particle::setPosition(Vector2f position)
{
m_Position = position;
}
Vector2f Particle::getPosition()
{
return m_Position;
}
所有这些函数都使用了我们之前见过的概念。构造函数使用传入的Vector2f
对象设置m_Velocity.x
和m_Velocity.y
值。
update
函数通过将m_Velocity
乘以经过的时间(dtAsSeconds
)来移动粒子的水平和垂直位置。注意,为了实现这一点,我们只需将两个Vector2f
对象相加即可。没有必要分别对 x 和 y 成员进行计算。
setPosition
函数,正如我们之前解释的,使用传入的值初始化m_Position
对象。getPosition
函数将m_Position
返回给调用代码。
我们现在有一个完全功能的Particle
类。接下来,我们将编写一个ParticleSystem
类来生成和控制粒子。
编写 ParticleSystem 类
ParticleSystem
类为我们处理大部分粒子效果的工作。我们将在这个Engine
类中创建这个类的实例。在我们这样做之前,让我们再谈谈面向对象编程和 SFML 的Drawable
类。
探索 SFML 的 Drawable 类和面向对象编程
Drawable
类只有一个函数。它也没有变量。此外,它的唯一函数是纯虚函数。这意味着,如果我们从Drawable
继承,我们必须实现它的唯一函数。提醒一下,从第十四章,抽象和代码管理 – 更好地利用面向对象编程,我们的类可以继承自drawable
作为多态类型。更简单地说,我们可以用继承自Drawable
的类做任何 SFML 允许我们对Drawable
对象做的事情。唯一的要求是我们必须为纯虚函数draw
提供一个定义。
一些继承自Drawable
的类已经包括Sprite
和VertexArray
(以及其他)。每次我们使用Sprite
或VertexArray
时,我们都会将它们传递给RenderWindow
类的draw
函数。
我们之所以能够在这整本书中绘制出我们曾经绘制过的每一个对象,是因为它们都继承自Drawable
。我们可以利用这一知识来发挥优势。
我们可以用我们喜欢的任何对象继承自Drawable
,只要我们实现了纯虚函数draw
。这也是一个简单直接的过程。考虑一个假设的SpaceShip
类。继承自Drawable
的SpaceShip
类的头文件(SpaceShip.h
)可能看起来像这样:
class SpaceShip : public Drawable
{
private:
Sprite m_Sprite;
// More private members
public:
virtual void draw(RenderTarget& target,
RenderStates states) const;
// More public members
};
在前面的代码中,我们可以看到纯虚函数draw
和一个Sprite
实例。注意,在类外部无法访问私有的Sprite
– 甚至没有getSprite
函数!
SpaceShip.cpp
文件可能看起来像这样:
void SpaceShip::SpaceShip
{
// Set up the spaceship
}
void SpaceShip::draw(RenderTarget& target, RenderStates states) const
{
target.draw(m_Sprite, states);
}
// Any other functions
在前面的代码中,注意 draw
函数的简单实现。这些参数超出了本书的范围。只需记住,target
参数用于调用 draw
函数,并传入 m_Sprite
以及其他参数。
小贴士
虽然理解参数并非充分利用 Drawable
的必要条件,但在本书的语境中,你可能对此感到好奇。你可以在 SFML 网站上了解更多关于 SFML Drawable
的信息:www.sfml-dev.org/tutorials/2.5/graphics-vertex-array.php
。
在主游戏循环中,我们现在可以将 SpaceShip
实例视为 Sprite
或任何继承自 Drawable
的其他类,如下所示:
SpaceShip m_SpaceShip;
// create other objects here
// ...
// In the draw function
// Rub out the last frame
m_Window.clear(Color::Black);
// Draw the spaceship
m_Window.draw(m_SpaceShip);
// More drawing here
// ...
// Show everything we have just drawn
m_Window.display();
正是因为 SpaceShip
是 Drawable
,我们才能将其视为 Sprite
或 VertexArray
,并且因为我们重写了纯虚函数 draw
,所以一切都能按预期工作。你将在本章中使用这种方法来绘制粒子系统。
在我们讨论面向对象编程(OOP)的主题时,让我们看看将绘图代码封装到游戏对象中的另一种方法,这是我们将在下一个项目中使用的方法。
继承自 Drawable
的替代方案
通过在类中实现自己的函数,我们也可以将所有绘图功能保留在要绘制的对象所在的类中,例如使用以下代码:
void drawThisObject(RenderWindow window)
{
window.draw(m_Sprite)
}
之前的代码假设 m_Sprite
代表当前我们正在绘制的类的视觉外观,正如在本项目和上一个项目中一样。假设包含 drawThisObject
函数的类的实例被称作 playerHero
,并且进一步假设我们有一个名为 m_Window
的 RenderWindow
实例,我们就可以使用以下代码从主游戏循环中绘制对象:
playerHero.draw(m_Window);
在这个解决方案中,我们将 RenderWindow
的 m_Window
作为参数传递给 drawThisObject
函数。然后,drawThisObject
函数使用 RenderWindow
来绘制 Sprite
,m_Sprite
。
如果我们有一组更复杂的游戏对象,那么在每一帧将 RenderWindow
的引用传递给要绘制的对象,以便它能够自行绘制,这是一种很好的策略。
我们将在本书的最终项目中使用这种策略,我们将在下一章开始。让我们通过编码继承自 Drawable
的 ParticleSystem
类来完成粒子系统,这将继承自 ParticleSystem
类。
编码 ParticleSystem.h
右键点击 ParticleSystem.h
。最后,点击 ParticleSystem
类。
将 ParticleSystem
类的代码添加到 ParticleSystem.h
中:
#pragma once
#include <SFML/Graphics.hpp>
#include "Particle.h"
using namespace sf;
using namespace std;
class ParticleSystem : public Drawable
{
private:
vector<Particle> m_Particles;
VertexArray m_Vertices;
float m_Duration;
bool m_IsRunning = false;
public:
virtual void draw(RenderTarget& target,
RenderStates states) const;
void init(int count);
void emitParticles(Vector2f position);
void update(float elapsed);
bool running();
};
我们一点一点地来。首先,注意我们正在继承自 SFML 的 Drawable
类。这将允许我们将 ParticleSystem
实例传递给 m_Window.draw
,因为 ParticleSystem
Drawable
。而且,由于我们继承自 Drawable
,我们可以使用与 Drawable
类内部使用的相同函数签名来重写 draw
函数。简而言之,当我们使用 ParticleSystem
类时,我们会看到以下代码。
m_Window.draw(m_PS);
m_PS
对象是我们 ParticleSystem
类的一个实例,我们将直接将其传递给 RenderWindow
类的 draw
函数,就像我们为 Sprite
、VertexArray
和 RectangleShape
实例所做的那样。所有这一切都是通过继承和多态的力量实现的。
提示
还不要添加 m_Window.draw…
代码;我们还有更多的工作要做。
有一个名为 m_Particles
的 Particle
类型的向量。这个向量将保存每个 Particle
实例。接下来,我们有一个名为 m_Vertices
的 VertexArray
。这将用于以大量 Point
原语的形式绘制所有粒子。
m_Duration
,float
变量表示每个效果将持续多长时间。我们将在构造函数中初始化它。
m_IsRunning
布尔变量将用于指示粒子系统是否正在使用中。
接下来,在公共部分,我们有一个纯虚函数 draw
,我们很快就会实现它来处理当我们传递 ParticleSystem
实例到 m_Window.draw
时会发生什么。
init
函数将准备 VertexArray
和 vector
。它还将使用速度和初始位置初始化所有 Particle
对象(由 vector
持有)。
update
函数将遍历 vector
中的每个 Particle
实例,并调用它们的单个 update
函数。
running
函数提供了访问 m_IsRunning
变量的权限,以便游戏引擎可以查询 ParticleSystem
是否正在使用中。
让我们编写函数定义,看看 ParticleSystem
内部发生了什么。
编写 ParticleSystem.cpp
文件
右键点击 ParticleSystem.cpp
。最后,点击 ParticleSystem
类的 .cpp
文件。
我们将把这个文件分成五个部分,这样我们就可以更详细地进行编码和讨论。添加以下代码的第一部分:
#include <SFML/Graphics.hpp>
#include "ParticleSystem.h"
using namespace sf;
using namespace std;
void ParticleSystem::init(int numParticles)
{
m_Vertices.setPrimitiveType(Points);
m_Vertices.resize(numParticles);
// Create the particles
for (int i = 0; i < numParticles; i++)
{
srand(time(0) + i);
float angle = (rand() % 360) * 3.14f / 180.f;
float speed = (rand() % 600) + 600.f;
Vector2f direction;
direction = Vector2f(cos(angle) * speed,
sin(angle) * speed);
m_Particles.push_back(Particle(direction));
}
}
在必要的 includes
之后,我们有 init
函数的定义。我们使用 Points
作为参数调用 setPrimitiveType
,以便 m_VertexArray
知道它将处理哪种类型的原语。我们使用 numParticles
调整 m_Vertices
的大小,这是在调用 init
函数时传递给它的。
for
循环为速度和角度创建随机值。然后使用三角函数将这些值转换为存储在 Vector2f
,direction
中的向量。
提示
如果你想了解更多关于如何使用三角函数(cos
和sin
)将角度和速度转换为向量,你可以查看这篇文章系列:gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/
。
在for
循环(以及init
函数)中最后发生的事情是将向量传递给Particle
构造函数。新的Particle
实例使用push_back
函数存储在m_Particles
中。因此,调用init
并传入1000
的值意味着我们在m_Particles
中有 1,000 个Particle
实例,它们具有随机速度,正等待着爆炸!
接下来,将update
函数添加到ParticleSysytem.cpp
中:
void ParticleSystem::update(float dt)
{
m_Duration -= dt;
vector<Particle>::iterator i;
int currentVertex = 0;
for (i = m_Particles.begin(); i != m_Particles.end(); i++)
{
// Move the particle
(*i).update(dt);
// Update the vertex array
m_Vertices[currentVertex++].position = i->getPosition();
}
if (m_Duration < 0)
{
m_IsRunning = false;
}
}
update
函数看起来比实际要简单。首先,m_Duration
通过传入的时间dt
减少。这样我们就可以知道两秒钟是否已经过去。声明了一个向量迭代器i
用于m_Particles
。
for
循环遍历m_Particles
中的每个Particle
实例。对于每一个实例,它调用其update
函数并传入dt
。每个粒子将更新其位置。粒子更新后,使用粒子的getPosition
函数更新m_Vertices
中适当的顶点。每次for
循环结束时,currentVertex
增加,为下一个顶点做准备。
for
循环完成后,代码检查if(m_Duration < 0)
是否是时候关闭效果。如果已经过去了两秒钟,m_IsRunning
被设置为false
。
接下来,添加emitParticles
函数:
void ParticleSystem::emitParticles(Vector2f startPosition)
{
m_IsRunning = true;
m_Duration = 2;
int currentVertex = 0;
for (auto it = m_Particles.begin();
it != m_Particles.end();
it++)
{
m_Vertices[currentVertex++].color = Color::Yellow;
it->setPosition(startPosition);
}
}
这是我们将调用来启动粒子系统的函数。所以,不出所料,我们将m_IsRunning
设置为true
,将m_Duration
设置为2
。我们声明了一个迭代器i
来遍历m_Particles
中的所有Particle
对象,然后在for
循环中这样做。
在for
循环内部,我们将顶点数组中的每个粒子设置为黄色,并将每个位置设置为传入的参数startPosition
。记住,每个粒子从相同的位置开始,但它们被分配了不同的速度。
接下来,添加纯虚draw
函数的定义:
void ParticleSystem::
draw(RenderTarget& target,
RenderStates states) const
{
target.draw(m_Vertices, states);
}
在前面的代码中,我们简单地使用target
调用draw
,传入m_Vertices
和states
作为参数。记住,我们永远不会直接调用这个函数!不久,当我们声明ParticleSystem
的实例时,我们将该实例传递给RenderWindow draw
函数。我们刚刚编写的draw
函数将从那里内部调用。
最后,添加running
函数:
bool ParticleSystem::running()
{
return m_IsRunning;
}
running
函数是一个简单的 getter 函数,它返回m_IsRunning
的值。我们将在本章中看到它的用途,以便我们可以确定粒子系统的当前状态。
使用ParticleSystem
对象
将我们的粒子
系统要工作的方式非常简单,尤其是在我们继承了Drawable
之后。
将ParticleSystem
对象添加到Engine
类中
打开Engine.h
文件,并添加一个ParticleSystem
对象,如下所示突出显示的代码:
#pragma once
#include <SFML/Graphics.hpp>
#include "TextureHolder.h"
#include "Thomas.h"
#include "Bob.h"
#include "LevelManager.h"
#include "SoundManager.h"
#include "HUD.h"
#include "ParticleSystem.h"
using namespace sf;
class Engine
{
private:
// The texture holder
TextureHolder th;
// create a particle system
ParticleSystem m_PS;
// Thomas and his friend, Bob
Thomas m_Thomas;
Bob m_Bob;
现在,我们需要初始化系统。
初始化粒子系统
打开Engine.cpp
文件,并在Engine
构造函数的末尾添加以下简短的突出显示代码:
Engine::Engine()
{
// Get the screen resolution and create an SFML window and View
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(resolution.x, resolution.y),
"Thomas was late",
Style::Fullscreen);
// Initialize the full screen view
m_MainView.setSize(resolution);
m_HudView.reset(
FloatRect(0, 0, resolution.x, resolution.y));
// Initialize the split-screen Views
m_LeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_RightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
m_BGLeftView.setViewport(
FloatRect(0.001f, 0.001f, 0.498f, 0.998f));
m_BGRightView.setViewport(
FloatRect(0.5f, 0.001f, 0.499f, 0.998f));
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
m_Window.close();
}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
// Associate the sprite with the texture
m_BackgroundSprite.setTexture(m_BackgroundTexture);
// Load the texture for the background vertex array
m_TextureTiles = TextureHolder::GetTexture(
"graphics/tiles_sheet.png");
// Initialize the particle system
m_PS.init(1000);
}// End Engine constructor
VertexArray
和Particle
实例的vector
已经准备好行动了。
每帧更新粒子系统
打开Update.cpp
文件,并添加以下突出显示的代码。它可以直接放在update
函数的末尾:
// Update the HUD every m_TargetFramesPerHUDUpdate frames
if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate)
{
// Update game HUD text
stringstream ssTime;
stringstream ssLevel;
// Update the time text
ssTime << (int)m_TimeRemaining;
m_Hud.setTime(ssTime.str());
// Update the level text
ssLevel << "Level:" << m_LM.getCurrentLevel();
m_Hud.setLevel(ssLevel.str());
m_FramesSinceLastHUDUpdate = 0;
}
// Update the particles
if (m_PS.running())
{
m_PS.update(dtAsSeconds);
}
}// End of update function
之前代码中所需的一切就是调用update
。注意,它被包裹在一个检查中,以确保系统目前正在运行。如果它没有运行,就没有更新它的必要。
启动粒子系统
打开DetectCollisions.cpp
文件,其中包含detectCollisions
函数。我们在最初编写代码时在其中留下了注释。
从上下文中识别正确的位置,并添加以下突出显示的代码:
// Is character colliding with a regular block
if (m_ArrayLevel[y][x] == 1)
{
if (character.getRight().intersects(block))
{
character.stopRight(block.left);
}
else if (character.getLeft().intersects(block))
{
character.stopLeft(block.left);
}
if (character.getFeet().intersects(block))
{
character.stopFalling(block.top);
}
else if (character.getHead().intersects(block))
{
character.stopJump();
}
}
// More collision detection here once
// we have learned about particle effects
// Have the characters' feet touched fire or water?
// If so, start a particle effect
// Make sure this is the first time we have detected this
// by seeing if an effect is already running
if (!m_PS.running()) {
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
{
if (character.getFeet().intersects(block))
{
// position and start the particle system
m_PS.emitParticles(character.getCenter());
}
}
}
// Has the character reached the goal?
if (m_ArrayLevel[y][x] == 4)
{
// Character has reached the goal
reachedGoal = true;
}
首先,代码检查粒子系统是否已经在运行。如果不是,它会检查当前正在检查的瓷砖是否是水或火瓷砖。如果是其中之一,它会检查角色的脚是否与之接触。当这些if
语句中的每一个都为真时,通过调用emitParticles
函数并传入角色的中心位置作为开始效果的坐标,粒子系统将通过调用emitParticles
函数并传入角色的中心位置作为开始效果的坐标来启动。
绘制粒子系统
这是最精彩的部分。看看绘制ParticleSystem
有多简单。我们在确认粒子系统正在运行后,直接将我们的实例传递给m_Window.draw
函数。
打开Draw.cpp
文件,并在所有必要的位置添加以下突出显示的代码:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
m_Window.draw(m_BackgroundSprite);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}
注意,我们必须在所有左、右和全屏代码块中绘制粒子系统。
运行游戏,并将其中一个角色的脚移到火瓷砖的边缘。注意粒子系统瞬间活跃起来:
现在,是时候介绍一些新的内容了。
OpenGL、着色器和 GLSL
开放图形库(OpenGL)是一个处理 2D 和 3D 图形的编程库。OpenGL 在所有主要的桌面操作系统上运行,还有一个版本可以在移动设备上运行,称为 OpenGL ES。
OpenGL 最初于 1992 年发布。经过二十多年的改进和优化。此外,显卡制造商设计他们的硬件以使其与 OpenGL 良好配合。提到这一点并不是为了历史课,而是为了解释尝试改进 OpenGL 并在桌面上的 2D(和 3D 游戏)中使用它将是一个徒劳的行为,尤其是如果我们希望我们的游戏在 Windows 以外的操作系统上运行,这是显而易见的选择。我们已经在使用 OpenGL,因为 SFML 使用了 OpenGL。着色器是运行在 GPU 上的程序。我们将在下一节中了解更多关于它们的信息。
可编程管道和着色器
通过 OpenGL,我们可以访问RenderWindow
实例的draw
函数。我们还可以编写在调用draw
之后可以在 GPU 上运行的代码,以独立操纵每个像素。这是一个非常强大的功能。
在 GPU 上运行的额外代码被称为着色器程序。我们可以编写代码来操纵我们的图形的几何(位置)在顶点着色器中。我们也可以编写代码来单独操纵每个像素的外观。这被称为片段着色器。
虽然我们不会深入探讨着色器,但我们将使用GL 着色器语言(GLSL)编写一些着色器代码,并一窥它提供的可能性。
在 OpenGL 中,一切都是一个点、一条线或一个三角形。此外,我们还可以将颜色和纹理附加到这些基本几何形状上,我们还可以将这些元素组合起来,以制作出我们在现代游戏中看到的复杂图形。这些统称为VertexArray
,以及Sprite
和Shape
类。
除了基本图形元素外,OpenGL 还使用矩阵。矩阵是一种进行算术运算的方法和结构。这种算术运算可以非常简单,例如移动(平移)坐标,也可以非常复杂,例如执行更高级的数学运算,例如将我们的游戏世界坐标转换为 GPU 可以使用的 OpenGL 屏幕坐标。幸运的是,SFML 在幕后为我们处理了这种复杂性。SFML 还允许我们直接处理 OpenGL。
小贴士
如果你想了解更多关于 OpenGL 的信息,你可以从这里开始:learnopengl.com/#!Introduction
。如果你想直接使用 OpenGL,同时使用 SFML,你可以阅读这篇文章来获取更多信息:www.sfml-dev.org/tutorials/2.5/window-opengl.php
。
一个应用程序可以有多个着色器。然后我们可以将不同的着色器附加到不同的游戏对象上以创建所需的效果。在这个游戏中,我们只有一个顶点着色器和一个片段着色器。我们将将其应用于每一帧,以及背景。
然而,当你看到如何将着色器附加到 draw
调用时,很明显可以轻松地拥有更多着色器。
我们将遵循以下步骤:
-
首先,我们需要为将在 GPU 上执行的着色器代码。
-
然后,我们需要编译这段代码。
-
最后,我们需要将着色器附加到我们游戏引擎的绘制函数中适当的
draw
函数调用。
GLSL 是一种语言,它也有自己的类型,以及这些类型的变量,可以声明和使用。此外,我们可以从我们的 C++ 代码中与着色器程序的变量进行交互。
正如我们将看到的,GLSL 有一些与 C++ 的语法相似之处。
编写片段着色器
这里是 shaders
文件夹中 rippleShader.frag
文件的代码。我们不需要编写这个,因为它包含在我们之前添加的资产中,在 第十四章,抽象和代码管理 – 更好地利用面向对象编程:
// attributes from vertShader.vert
varying vec4 vColor;
varying vec2 vTexCoord;
// uniforms
uniform sampler2D uTexture;
uniform float uTime;
void main() {
float coef = sin(gl_FragCoord.y * 0.1 + 1 * uTime);
vTexCoord.y += coef * 0.03;
gl_FragColor = vColor * texture2D(uTexture, vTexCoord);
}
前四行(不包括注释)是片段着色器将使用的变量,但它们不是普通变量。我们首先看到的是 varying
类型。这些变量在两个 shaders
之间都有作用域。接下来,我们有 uniform
变量。这些变量可以直接从我们的 C++ 代码中操作。我们很快就会看到如何做到这一点。
除了 varying
和 uniform
类型之外,每个变量还有一个更传统的类型,它定义了实际的数据,如下所示:
-
vec4
是一个包含四个值的向量。 -
vec2
是一个包含两个值的向量。 -
sampler2d
将保存一个纹理。 -
float
就像 C++ 中的float 数据类型
。
main
函数内部的代码被执行。如果我们仔细查看 main
中的代码,我们会看到正在使用的每个变量。这段代码的确切功能超出了本书的范围。然而,总的来说,纹理坐标(vTexCoord
)和像素/片段的颜色(glFragColor
)通过几个数学函数和操作进行操作。请记住,这将在我们游戏的每一帧中调用的 draw
函数涉及的每个像素上执行。此外,请注意 uTime
在每一帧中传递的值都不同。结果,正如我们很快将看到的,将会产生波纹效果。
编写顶点着色器
这里是 vertShader.vert
文件的代码。您不需要编写这个。它包含在我们之前添加的资产中,在 第十四章,抽象和代码管理 – 更好地利用面向对象编程:
//varying "out" variables to be used in the fragment shader
varying vec4 vColor;
varying vec2 vTexCoord;
void main() {
vColor = gl_Color;
vTexCoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
首先,注意两个 varying
变量。这些正是我们在片段着色器中操作的变量。在 main
函数中,代码操作每个顶点的位置。代码的工作原理超出了本书的范围,但幕后有一些相当深入的数学运算。如果您对此感兴趣,那么进一步探索 GLSL 将会非常有趣。
现在我们有了两个着色器(一个片段和一个顶点),我们可以在游戏中使用它们。
将着色器添加到引擎类中
打开 Engine.h
文件。添加以下高亮显示的代码行,它将一个名为 m_RippleShader
的 SFML Shader
实例添加到 Engine
类中:
// Three views for the background
View m_BGMainView;
View m_BGLeftView;
View m_BGRightView;
View m_HudView;
// Declare a sprite and a Texture for the background
Sprite m_BackgroundSprite;
Texture m_BackgroundTexture;
// Declare a shader for the background
Shader m_RippleShader;
// Is the game currently playing?
bool m_Playing = false;
// Is character 1 or 2 the current focus?
bool m_Character1 = true;
引擎对象及其所有功能现在都可以访问 m_RippleShader
。请注意,一个 SFML Shader
对象将包含着色器代码文件。
加载着色器
添加以下代码,检查玩家的 GPU 是否可以处理着色器。如果不行,游戏将退出。
小贴士
如果你的 GPU 不能处理着色器,你需要有一台非常旧的 PC 才能使这个功能不起作用。如果你有一个不支持着色器的 GPU,请接受我的道歉。
接下来,我们将添加一个 else
子句,如果系统可以处理着色器,则加载着色器。打开 Engine.cpp
文件,并将以下代码添加到构造函数中:
// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
// Time to get a new PC
// Or remove all the shader related code L
m_Window.close();
}
else
{
// Load two shaders (1 vertex, 1 fragment)
m_RippleShader.loadFromFile("shaders/vertShader.vert",
"shaders/rippleShader.frag");
}
m_BackgroundTexture = TextureHolder::GetTexture(
"graphics/background.png");
我们几乎可以看到我们的涟漪效果在行动了。
更新和绘制着色器
打开 Draw.cpp
文件。正如我们在编写着色器时已经讨论过的,我们将直接从我们的 C++代码中每帧更新 uTime
变量。我们将使用 setParameter
函数这样做。
添加以下高亮显示的代码以更新着色器的 uTime
变量,并更改对 m_BackgroundSprite
的 draw
调用,在每个可能的绘制场景中:
void Engine::draw()
{
// Rub out the last frame
m_Window.clear(Color::White);
// Update the shader parameters
m_RippleShader.setUniform("uTime",
m_GameTimeTotal.asSeconds());
if (!m_SplitScreen)
{
// Switch to background view
m_Window.setView(m_BGMainView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_MainView
m_Window.setView(m_MainView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
else
{
// Split-screen view is active
// First draw Thomas' side of the screen
// Switch to background view
m_Window.setView(m_BGLeftView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_LeftView
m_Window.setView(m_LeftView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Bob.getSprite());
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
// Now draw Bob's side of the screen
// Switch to background view
m_Window.setView(m_BGRightView);
// Draw the background
//m_Window.draw(m_BackgroundSprite);
// Draw the background, complete with shader effect
m_Window.draw(m_BackgroundSprite, &m_RippleShader);
// Switch to m_RightView
m_Window.setView(m_RightView);
// Draw the Level
m_Window.draw(m_VALevel, &m_TextureTiles);
// Draw thomas
m_Window.draw(m_Thomas.getSprite());
// Draw bob
m_Window.draw(m_Bob.getSprite());
// Draw the particle system
if (m_PS.running())
{
m_Window.draw(m_PS);
}
}
// Draw the HUD
// Switch to m_HudView
m_Window.setView(m_HudView);
m_Window.draw(m_Hud.getLevel());
m_Window.draw(m_Hud.getTime());
if (!m_Playing)
{
m_Window.draw(m_Hud.getMessage());
}
// Show everything we have just drawn
m_Window.display();
}
最好删除被注释掉的代码行。
运行游戏,你将得到一种神秘的熔岩岩石效果。尝试更改背景图像以获得乐趣:
就这样!我们的第四个游戏完成了。
摘要
在本章中,我们探讨了粒子系统和着色器的概念。尽管我们可能已经看到了每个的最简单案例,但我们仍然设法创建了一个简单的爆炸和一个神秘的熔岩岩石效果。
在接下来的四章中,我们将探讨更多使用设计模式改进我们代码的方法,同时构建《太空侵略者》游戏。
第二十章:第十九章:游戏编程设计模式 – 开始 Space Invaders ++ 游戏
欢迎来到最终项目。正如你现在所期待的,这个项目将在学习新的 C++ 技巧方面迈出重要的一步。接下来的四章将探讨诸如 智能指针、C++ 断言、使用游戏手柄控制器、使用 Visual Studio 进行调试、类型转换基类指针到特定派生类指针、调试以及设计模式的第一瞥等主题。
我猜测,如果你打算用 C++ 制作深度、大规模的游戏,那么设计模式将是你未来几个月和几年学习计划中的重要部分。为了介绍这个至关重要的主题,我选择了一个相对简单但有趣的游戏作为例子。在本章中,我们将更深入地了解 Space Invaders ++ 游戏,然后我们可以继续讨论设计模式及其必要性。
在这一章中,我们将涵盖以下主题:
-
了解 Space Invaders ++ 以及为什么我们选择它作为最终项目。
-
了解什么是设计模式以及为什么它们对游戏开发者很重要。
-
研究在接下来的四章中将在 Space Invaders ++ 项目中使用的各种设计模式。
-
我们将开始 Space Invaders ++ 项目。
-
编写多个类以开始充实游戏。
让我们谈谈游戏本身。
Space Invaders ++
看看以下三个截图,它们从视觉上解释了我们关于 Space Invaders ++ 需要知道的大部分内容。如果你还不知道,Space Invaders 是最早的街机游戏之一,于 1978 年发布。如果你对历史感兴趣,你可以在这里阅读维基百科上的 Space Invaders 游戏页面:en.wikipedia.org/wiki/Space_Invaders
。
这第一张截图显示了游戏简单的起始屏幕。为了讨论屏幕,我们将在下一部分进行讨论,我们将称这个屏幕为 选择屏幕。玩家有两个选择:退出或开始游戏。然而,在本章结束时,你将知道如何添加和切换你喜欢的任意多个屏幕:
如你所见,在前面的截图中有我们之前没有实现的新功能:可点击的按钮。我们将在稍后更多地讨论按钮及其对应物,如 UI 面板和屏幕。
以下截图显示了游戏的实际运行情况。游戏玩法相当简单。为了讨论屏幕,我们将在下一部分进行讨论,我们将称以下截图为 游戏屏幕。入侵者从左向右移动,并向玩家射击子弹。当他们到达屏幕边缘时,他们会稍微下降,加速,然后返回左侧:
玩家可以左右移动,也可以上下移动,但垂直移动仅限于屏幕的下半部分。
重要提示
原始的 Space Invaders 游戏只允许水平移动。
以下截图显示了玩家在失去三次生命时可以选择的选项。他们可以选择再次玩游戏或退出并返回选择屏幕:
虽然 Space Invaders ++ 允许我们介绍许多我在章节介绍中已经提到的新的 C++主题,以及一些与游戏相关的主题,例如使用游戏手柄控制器,但确实,与之前的项目相比,在复杂性方面并没有真正的提升。那么,为什么选择这个作为最终项目呢?
重要提示
在这个项目中,有很多代码。其中大部分我们在之前已经见过,无论是在相同的环境中还是在不同的环境中。不可能解释每一行代码,因为这需要一本全新的书来做到。我已经非常仔细地选择了要完整解释的代码、只需提及的代码以及我猜测您自己能够解决的代码。我建议您在进步的过程中学习这本书和下载包中的所有代码。然而,我将会详细解释代码的结构,因为这确实是这个项目的真正学习目标。此外,本书中展示了所有的 C++代码,所以没有遗漏,尽管只展示了level1.txt
文件的概述。
为什么选择 Space Invaders ++?
为了开始这次讨论,请考虑我为本书设定的两个目标:
-
本书第一个目标是向您介绍使用视频游戏学习材料进行 C++编程。我已经在多个场合和多个主题上承认,这只是一个入门。C++和游戏开发太大,无法仅凭这本书来涵盖。
-
本书第二个目标是让您在继续学习的同时,仍然可以使用游戏作为学习材料。
问题在于,正如我们所看到的,每次我们构建一个比上一个游戏功能更多的游戏,我们最终都会得到一个更复杂的代码结构,代码文件也越来越长。在整个书中,我们学习了新的方法来改进我们的代码结构,在每一个阶段我们都取得了成功,但游戏的日益复杂性似乎总是超过了我们学到的代码改进。
这个项目旨在解决这个复杂性问题,并重新控制我们的源代码。尽管这个游戏比之前的项目深度要低,但需要处理的类将远更多。
这显然意味着一个非常复杂的结构。然而,一旦您掌握了这个结构,您将能够为更复杂的游戏重用它,而无需任何代码文件超过几百行代码。
这个项目旨在让您能够提出自己的游戏想法,即使是复杂的想法,并立即开始使用我们在下一节中将要讨论的设计模式。
小贴士
然而,我绝对不是在暗示这里我们将学习到的代码结构(设计模式)是解决你游戏开发未来的终极方案;实际上,它们离这个目标还远着呢。你将学到的是一些解决方案,这些解决方案将帮助你开始你的梦想项目,而不会因为复杂性而停滞不前。你仍然需要在过程中学习更多关于设计模式、C++和游戏开发的知识。
那么,什么是设计模式呢?
设计模式
设计模式是一种可重用的编码问题解决方案。实际上,大多数游戏(包括这个游戏)都会使用多个设计模式。关于设计模式的关键点在于:它们已经被证明能够为常见问题提供良好的解决方案。我们不会发明任何设计模式——我们只是将使用一些已经存在的设计模式来解决我们不断增长的代码中的问题。
许多设计模式相当复杂,如果你想要开始学习它们,就需要在本书的水平之上进行进一步的学习。以下是对一些关键游戏开发相关模式的简化,这将有助于实现本书的第二个目标。我们鼓励你继续学习,以便更全面地实现它们,并使用比这里讨论的更多的模式。
让我们看看在 Space Invaders ++项目中使用的设计模式。
屏幕、输入处理器、UI 面板和按钮
这个项目将比其他任何项目都进一步抽象化一些概念。Space Invaders ++将引入屏幕的概念。屏幕的概念可以通过一些例子来最容易地理解。一个游戏可以有一个菜单屏幕、设置屏幕、高分屏幕和游戏屏幕。屏幕是游戏各部分的逻辑划分。每个屏幕都与所有其他屏幕有一些共同点,但每个屏幕也需要其独特的功能。例如,菜单屏幕可能有一些按钮,允许玩家切换到另一个屏幕,以及一个整洁的图形图像,甚至是一个动态场景。高分屏幕当然会有一份所有高分列表,也许还有一个按钮可以返回到菜单屏幕。每个屏幕将具有不同的布局、不同的按钮可以点击,以及对不同键盘按键的不同响应,但它们都需要以 60 FPS 的速度绘制,并以相同的方式与游戏引擎交互。
在之前的项目中,我们将屏幕的概念压缩到了一个地方。这意味着我们有了处理更新、绘制和响应用户交互的长长的if
、else
和else if
代码块。我们的代码已经变得很难处理了。如果我们打算构建更复杂的游戏,我们需要改进这一点。屏幕的概念意味着我们可以创建一个处理每个屏幕发生的所有事情(如更新、绘制和用户交互)的类,然后为每种类型的屏幕创建一个派生类,即菜单、游戏、高分等,这些类处理特定屏幕需要更新的、绘制的和响应用户的独特方式。
在《太空侵略者++》中,我们将有一个Screen
类。然后我们将从Screen
类继承以处理两个屏幕,即SelectScreen
和GameScreen
。此外,我们还将有一个知道如何显示按钮的Button
类,一个知道如何绘制文本的UIPanel
类,以及Button
实例以及一个知道如何检测键盘和游戏手柄交互的InputHandler
类。这样我们就能从UIPanel
和InputHandler
继承,让所有不同的Screen
实例都能按照要求精确地表现,而无需多次编写屏幕、UI 面板、输入处理程序或按钮的基础代码。你的游戏越大,屏幕越多,这种方式的益处就越大。这也意味着每个屏幕的具体细节不会像我们之前所做的那样被塞进长长的if
、else
和else if
结构中。
这有点像我们编写PlayableCharacter
类并从中派生出Thomas
和Bob
的方式。然而,正如我们将看到的,这次我们的抽象程度要高得多。看看下面的图表,它展示了这个想法的表示,并且只显示了一个屏幕:
在前面的图表中,我们可以看到屏幕有一个或多个它可以选择性显示的UIPanel
实例,并且UIPanel
实例可以有零个或多个Button
实例。每个UIPanel
都将有一个相关的InputHandler
,因为每个UIPanel
将具有不同的按钮组合和布局。按钮通过指针在UIPanel
和InputHandler
实例之间共享。
如果你想知道哪个类处理游戏循环的更新阶段,答案是Screen
类。然而,一旦你理解了这种模式的工作原理,添加让UIPanel
实例在更新阶段也能行动的能力将会变得简单。如果,比如说,面板需要移动或者可能显示一个加载进度条,这将是有用的。
一个屏幕将决定哪些 UIPanel
(因此,InputHandler
)实例当前可见并响应。然而,一次只有一个屏幕对玩家可见。我们将编写一个 ScreenManager
类,这将成为游戏引擎的基本部分,用于调用适当(当前)屏幕的关键功能。ScreenManager
类还将提供一种方式,让 InputHandler
实例在需要屏幕切换时通知我们,例如,当玩家在选择屏幕上点击 Play 按钮以进入游戏屏幕。
ScreenManager
将保存每个屏幕的实例,记住玩家当前所在的屏幕,并在正确的屏幕上调用 update
、draw
和 handleInput
,以及在需要时切换屏幕。以下图表可能会帮助你可视化这个概念,我们也将很快进行编码:
注意,图表和解释是对我们将要编写的解决方案的简化,但它们提供了一个很好的概述。
如果你想在现有屏幕上添加高分屏幕或另一个 UIPanel
实例,你将在结束 第二十二章,使用游戏对象和构建游戏 时知道如何这样做。当然,你很可能会想开始你自己的游戏。你将能够将你的下一款游戏划分为你需要的大量屏幕,每个屏幕都有其专门的布局和输入处理。
实体-组件模式
现在,我们将花五分钟沉浸在看似无法解决的混乱中。然后,我们将看到实体-组件模式如何拯救我们。
为什么大量多样的对象类型难以管理
在之前的项目中,我们为每个对象编写了一个类。我们有像 Bat、Ball、Crawler 和 Thomas 这样的类。然后在 update
函数中,我们会更新它们,在 draw
函数中,我们会绘制它们。每个对象决定如何进行更新和绘制。
我们可以开始使用相同的结构来为 Space Invaders ++ 编码。它会工作,但我们正在尝试学习更易于管理的知识,以便我们的游戏可以增加复杂性。
这种方法的一个问题是,我们无法利用继承。例如,所有入侵者、子弹和玩家都以相同的方式绘制自己,但除非我们改变做事的方式,否则我们最终会得到三个几乎相同的 draw
函数。如果我们更改调用 draw
函数的方式或处理图形的方式,我们需要更新所有三个类。
必须有更好的方法。
使用通用的 GameObject 来优化代码结构
如果每个对象、玩家、外星人和所有子弹都是一种通用类型,那么我们可以将它们打包到一个 vector
实例中,并遍历它们的每个 update
函数,然后是每个 draw
函数。
我们已经知道一种做这件事的方法——继承。乍一看,继承可能看起来是一个完美的解决方案。我们可以创建一个抽象的GameObject
类,然后通过Player
、Invader
和Bullet
类来扩展它。
在所有三个类中相同的draw
函数可以保留在父类中,我们也不会有那么多浪费的重复代码问题。太好了!
这种方法的问题在于游戏对象在某种程度上是多么多样化。多样性不是一种优势;它只是多样化。例如,所有对象类型移动方式不同。子弹向上或向下移动,入侵者左右移动并偶尔下降,玩家的飞船对输入做出反应。
我们如何将这种多样性融入到update
中,以便它能控制这种运动?也许我们可以使用类似的东西:
update(){
switch(objectType){
case 1:
// All the player's logic
break;
case 2:
// All the invader's logic here
Break;
case 3:
// All the bullet's logic here
break;
}
}
单独的update
函数就会比整个GameEngine
类还要大!
如您从第十五章中可能记得,高级面向对象编程——继承和多态,当我们从一个类继承时,我们也可以覆盖特定的函数。这意味着我们可以为每种对象类型拥有不同的update
函数版本。然而,不幸的是,这种方法也存在问题。
GameEngine
引擎必须“知道”它正在更新哪种类型的对象,或者至少能够查询它正在更新的GameObject
实例,以便调用正确的update
函数版本。真正需要的是GameObject
以某种方式内部选择所需的更新函数
版本。
不幸的是,即使是看起来似乎可行的解决方案,在仔细检查时也会分崩离析。我说过,draw
函数中的代码对所有三个对象都是相同的,因此draw
函数可以是父类的一部分,并由所有子类使用,而不是我们必须编写三个单独的draw
函数。那么,当我们引入一个需要以不同方式绘制的新的对象时,比如一个飞越屏幕顶部的动画不明飞行物(UFO)时,会发生什么呢?在这种情况下,绘制解决方案也会崩溃。
现在我们已经看到了当对象彼此不同但又渴望属于同一个父类时出现的问题,是时候看看我们在 Space Invaders ++项目中将要使用的解决方案了。
我们需要的是一种新的思考方式来构建我们所有的游戏对象。
优先考虑组合而非继承
优先考虑组合而非继承指的是用其他对象组合对象的想法。
重要提示
这个概念最初是在以下出版物中提出的:
《设计模式:可复用面向对象软件元素》
由 Erich Gamma, Richard Helm 等人所著。
如果我们能够编写一个处理对象绘制方式的类(而不是函数),那么对于所有以相同方式绘制的类,我们可以在GameObject
内部实例化这些特殊的绘制类,而任何需要以不同方式绘制的对象都可以拥有不同的绘制对象。然后,当GameObject
执行不同的操作时,我们只需将其与不同的绘制或更新相关类组合即可。我们所有对象的所有相似之处都可以通过使用相同的代码来受益,而所有差异则不仅可以被封装,还可以被抽象(从基类中提取)。
注意,本节的标题是组合优于继承,而不是组合代替继承。组合并不取代继承,你在第十五章,“高级面向对象编程 – 继承和多态”中学到的所有内容仍然适用。然而,在可能的情况下,应使用组合而不是继承。
GameObject
类是实体,而它将组合的执行诸如更新位置和绘制到屏幕上的操作的类是组件,这就是为什么我们称之为实体-组件模式。
请看以下图表,它表示了我们将在本项目中所实现的实体-组件模式:
在前面的图表中,我们可以看到GameObject
实例由多个Component
实例组成。将会有多个从Component
类派生的不同类,包括UpdateComponent
和GraphicsComponent
。此外,还可以从它们进一步派生出更具体的类。例如,BulletUpdateComponent
和InvaderUpdateComponent
类将从UpdateComponent
类派生。这些类将处理子弹和入侵者(分别)在游戏每一帧如何更新自己。这对于封装来说非常好,因为我们不需要大型的switch
块来区分不同的对象。
当我们使用组合优于继承来创建一组表示行为/算法的类,正如我们在这里所做的那样,这被称为策略模式。你可以使用在这里学到的所有内容,并将其称为策略模式。实体-组件是一种不太为人所知但更具体的实现,这就是我们这样称呼它的原因。这种区别是学术性的,但如果你想要进一步探索,请随时查阅谷歌。在第二十三章,“在出发前…”,我会向你展示一些这类详细研究的优质资源。
实体-组件模式,以及相对于继承更倾向于使用组合,乍一看听起来很棒,但它也带来了一些自己的问题。这意味着我们的新 GameObject
类将需要了解所有不同类型的组件以及游戏中每一种单独的对象。它将如何为自己添加所有正确的组件?
让我们看看解决方案。
工厂模式
诚然,如果我们想要这样一个通用的 GameObject
类,它可以成为我们想要的任何东西,无论是子弹、玩家、入侵者还是其他任何东西,那么我们就必须编写一些“知道”如何构建这些超级灵活的 GameObject
实例,并用正确的组件来组合它们的逻辑。但将所有这些代码添加到类中会使它变得异常难以管理,并且违背了最初使用实体-组件模式的原因。
我们需要一个构造器,它能够执行类似于这个假设的 GameObject
代码:
class GameObject
{
UpdateComponent* m_UpdateComponent;
GraphicsComponent* m_GraphicsComponent;
// More components
// The constructor
GameObject(string type){
if(type == "invader")
{
m_UpdateComp = new InvaderUpdateComponent();
m_GraphicsComponent = new StdGraphicsComponent();
}
else if(type =="ufo")
{
m_UpdateComponent = new
UFOUpdateComponentComponent();
m_GraphicsComponent = new AnimGraphicsComponent();
}
// etc.
…
}
};
GameObject
类不仅需要知道哪些组件与哪个 GameObject
实例相关联,还需要知道哪些组件不需要,例如控制玩家的输入相关组件。对于 Space Invaders ++ 项目,我们可以这样做并且勉强应对复杂性,但勉强应对并不是目标;我们想要完全控制。
GameObject
类还需要理解所有这些逻辑。使用实体-组件模式中组合而非继承所获得的任何好处或效率都将主要丧失。
此外,如果我们决定我们想要一种新的入侵者类型,比如一个“隐形者”外星人,它靠近玩家,开一枪,然后再次隐形离开?编写一个新的 GraphicsComponent
类,比如一个“隐形 GraphicsComponent”类,它“知道”何时可见和不可见,以及一个新的 UpdateComponent
,比如一个“隐形更新组件”,它通过传送而不是传统方式移动,是可以的,但不好的一点是我们将不得不在 GameObject
类构造器中添加一大堆新的 if
语句。
实际上,情况比这还要糟糕。如果我们决定常规入侵者现在可以隐形呢?入侵者现在不仅需要一个不同类型的 GraphicsComponent
类。我们还得回到 GameObject
类中去再次编辑所有那些 if
语句。
实际上,还有更多可以想象的情况,它们最终都会导致 GameObject
类越来越大。与实体-组件模式完美匹配的 GameObject
类相关的问题。
重要提示
这种工厂模式的实现是一个更容易开始学习工厂模式的方法。完成这个项目后,为什么不进行一次网络搜索,看看工厂模式如何得到改进?
游戏设计师将为游戏中的每种类型的对象提供规范,程序员将提供一个工厂类,该类根据游戏设计师的规范构建GameObject
实例。当游戏设计师提出关于实体的新想法时,我们只需要请求一个新的规范。有时,这可能意味着在工厂中添加一个新的生产线,该生产线使用现有的组件,有时则意味着编写新的组件或更新现有组件。关键是,无论游戏设计师多么有创意,GameObject
和GameEngine
类都不会改变。
在工厂代码中,当前的对象类型会被检查,并添加适当的组件(类)到其中。子弹、玩家和入侵者具有相同的图形组件,但所有这些都有不同的更新组件。
当我们使用组合时,可能不太清楚哪个类负责内存。是创建它的类,使用它的类,还是其他某个类?让我们学习更多的 C++知识,以便更简单地管理内存。
C++智能指针
智能指针是我们可以使用来获得与常规指针相同功能但具有额外功能的类——这个额外功能就是它们会负责自己的删除。在我们迄今为止有限地使用指针的方式中,我们删除自己的内存并没有问题,但随着你的代码变得更加复杂,当你在一个类中分配新内存但在另一个类中使用它时,就变得不太清楚哪个类负责在完成使用后删除内存。一个类或函数如何知道另一个类或函数是否已经完成了对一些已分配内存的使用?
解决方案是智能指针。有几种类型的智能指针;在这里,我们将查看两种最常用的类型。使用智能指针成功的关键是使用正确的类型。
我们首先考虑的是共享指针。
共享指针
共享指针可以安全地删除它所指向的内存的方式是记录对内存区域的不同引用的数量。如果你将指针传递给一个函数,计数会增加一个。如果你将指针放入一个向量中,计数会增加一个。如果函数返回,计数会减少一个。如果向量超出作用域或对其调用clear
函数,智能指针会将引用计数减少一个。当引用计数为零时,不再有任何东西指向该内存区域,智能指针类会调用delete
。所有智能指针类都是在幕后使用常规指针实现的。我们只是得到了不必担心在哪里或何时调用delete
的好处。让我们看看使用共享智能指针的代码。
以下代码创建了一个名为myPointer
的新共享智能指针,它将指向MyClass
的一个实例:
shared_ptr<MyClass> myPointer;
shared_ptr<MyClass>
是类型,而myPointer
是它的名称。以下代码是初始化myPointer
的方式:
myPointer = make_shared<MyClass>();
make_shared
的调用在内部调用new
来分配内存。括号()
是构造函数括号。例如,如果MyClass
类的构造函数接受一个int
参数,前面的代码可能看起来像这样:
myPointer = make_shared<MyClass>(3);
代码中前面的3
是一个任意示例。
当然,如果需要,你可以在一行代码中声明和初始化你的共享智能指针,如下面的代码所示:
shared_ptr<MyClass> myPointer = make_shared<MyClass>();
正因为myPointer
是一个shared_ptr
,它有一个内部引用计数,用于跟踪指向它创建的内存区域的引用数量。如果我们复制指针,引用计数会增加。
复制指针包括将指针传递给另一个函数,将其放入vector
、map
或其他结构中,或者简单地复制它。
我们可以使用与常规指针相同的语法使用智能指针。有时很容易忘记它不是一个常规指针。以下代码在myPointer
上调用myFunction
函数:
myPointer->myFunction();
通过使用共享智能指针,会有一些性能和内存开销。这里的开销是指我们的代码运行得更慢,并且使用更多的内存。毕竟,智能指针需要一个变量来跟踪引用计数,并且每次引用超出作用域时都必须检查引用计数的值。然而,这种开销非常小,只有在最极端的情况下才是一个问题,因为大部分开销发生在智能指针创建的过程中。通常,我们会在游戏循环之外创建智能指针。在智能指针上调用函数与在常规指针上调用函数一样高效。
有时候,我们知道我们只想对智能指针有一个引用,在这种情况下,唯一指针是最好的选择。
唯一指针
当我们知道我们只想有一个对内存区域的引用时,我们可以使用唯一智能指针。唯一指针失去了我之前提到的共享指针的大部分开销。此外,如果你尝试复制一个唯一指针,编译器会警告我们,代码可能无法编译,或者会崩溃,给出一个清晰的错误。这是一个非常有用的功能,可以防止我们意外复制一个不应该复制的指针。你可能想知道,如果没有复制规则,我们是否永远不能将其传递给函数,甚至将其放入vector
等数据结构中。为了找出答案,让我们看看唯一智能指针的代码,并探索它们是如何工作的。
以下代码创建了一个名为myPointer
的唯一智能指针,它指向MyClass
的一个实例:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
现在,假设我们想向vector
中添加一个unique_ptr
。首先要注意的是,vector
必须是正确的类型。以下代码声明了一个包含MyClass
实例的唯一指针的vector
:
vector<unique_ptr<MyClass>> myVector;
vector
被命名为myVector
,你放入其中的任何东西都必须是MyClass
的唯一指针类型。但我说过唯一指针不能复制吗?当我们知道我们只想有一个内存区域的引用时,我们应该使用unique_ptr
。但这并不意味着引用不能移动。以下是一个例子:
// Use move() because otherwise
// the vector has a COPY which is not allowed
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Won't compile!
在前面的代码中,我们可以看到move
函数可以用来将唯一智能指针放入vector
中。请注意,当你使用move
函数时,你并不是在给编译器许可去打破规则并复制一个唯一指针——你是在将责任从myPointer
变量移动到myVector
实例。如果你在此之后尝试使用myPointer
变量,代码将执行,游戏将崩溃,给你一个空指针访问违规错误。以下代码将导致崩溃:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
vector<unique_ptr<MyClass>> myVector;
// Use move() because otherwise
// the vector has a COPY which is not allowed
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Won't compile!
myPointer->myFunction();// CRASH!!
当将唯一指针传递给函数时,也适用相同的规则;使用move
函数传递责任。当我们到达几页后的项目时,我们将再次查看所有这些场景,以及一些其他的场景。
类型转换智能指针
我们经常希望将派生类的智能指针打包到基类的数据结构或函数参数中,例如所有不同的派生Component
类。这是多态的本质。智能指针可以通过类型转换来实现这一点。但是,当我们后来需要访问派生类的功能或数据时会发生什么呢?
一个很好的例子是,当我们处理游戏对象内部的组件时,这将是经常必要的。将会有一个抽象的Component
类,从该类派生出的将会有GraphicsComponent
、UpdateComponent
等。
例如,我们希望在游戏循环的每一帧调用所有UpdateComponent
实例的update
函数。但如果所有组件都存储为基类Component
实例,那么这似乎是不可能的。从基类到派生类的类型转换解决了这个问题。
以下代码将myComponent
,一个基类Component
实例转换为UpdateComponent
类实例,然后我们可以调用update
函数:
shared_ptr<UpdateComponent> myUpdateComponent =
static_pointer_cast<UpdateComponent>(MyComponent);
在等号之前,声明了一个指向UpdateComponent
实例的新shared_ptr
。在等号之后,static_pointer_cast
函数指定了在尖括号中要转换到的类型<UpdateComponent>
,以及要转换的实例在括号中(MyComponent)
。
现在,我们可以使用UpdateComponent
类的所有功能,在我们的项目中包括update
函数。我们可以这样调用update
函数:
myUpdateComponent->update(fps);
我们可以将一个类的智能指针转换为另一个类的智能指针的两种方式。一种是通过使用 static_pointer_cast
,正如我们刚才看到的,另一种是使用 dynamic_pointer_cast
。区别在于,如果你不确定转换是否可行,可以使用 dynamic_pointer_cast
。当你使用 dynamic_pointer_cast
时,你可以通过检查结果是否为空指针来查看它是否成功。当你确定结果是你想要转换的类型时,使用 static_pointer_class
。在整个《太空侵略者 ++》项目中,我们将使用 static_pointer_cast
。
我们将经常将 Component
实例强制转换为不同的派生类型。当我们随着项目的进展进行转换时,我们将如何确保转换到的类型是正确的类型将变得明显。
C++ 断言
在这个项目中,我们将使用 C++ 断言。像往常一样,这个话题比我们在这里讨论的要多,但我们仍然可以通过简单的介绍做一些有用的事情。
我们可以在类中使用 #define
预处理器语句来为整个项目定义一个值。我们使用以下代码这样做:
#define debuggingOnConsole
此代码将写在头文件的最顶部。现在,在整个项目中,我们可以编写如下代码:
#ifdef debuggingOnConsole
// C++ code goes here
#endif
#ifdef debuggingOnConsole
语句检查是否存在 #define
debuggingOnConsole
语句。如果存在,则从 #ifdef
语句到 #endif
语句之间的任何 C++ 代码都将包含在游戏中。然后我们可以选择取消注释 #define
语句来打开或关闭调试代码。
通常,我们将在 #ifdef
块中包含如下代码:
#ifdef debuggingOnConsole
cout <<
"Problem x occurred and caused a crash!"
<< endl;
#endif
前面的代码使用 cout
语句将调试信息打印到控制台窗口。
这些断言实际上是一种从开发期间的游戏中获得反馈的方法,然后通过在 #define
语句前加上一个快速 //
,在我们完成时从游戏中移除所有调试代码。
创建《太空侵略者 ++》项目
你可以在本章末尾的 Space Invaders ++
文件夹中找到表示项目的可运行代码。它需要完成第 20、21 和 22 章的内容,才能使项目再次可运行。在 Space Invaders ++ 2
文件夹中可以找到表示项目末尾的、可运行的、完成代码,即 第二十二章([B14278_22_Final_AG_ePub.xhtml#_idTextAnchor445])使用游戏对象和构建游戏。
在 Visual Studio 中创建一个新的项目,使用与之前四个项目相同的设置。将新项目命名为 Space Invaders ++
。
在 Space Invaders ++
文件夹内,从下载包中复制并粘贴 fonts
、graphics
和 sound
文件夹及其内容。正如你所期望的,这些文件夹包含我们将用于本游戏的字体、图形和音频资源。
此外,您还需要从opengameart.org/content/background-night
下载背景文件。
重要提示
这幅图是opengameart.org/users/alekei
的作品。
您可以从opengameart.org/content/background-night
下载此文件。
您可以在creativecommons.org/licenses/by/3.0/
找到许可证。
将你刚刚下载的文件重命名为background.png
,并将其放置在项目中的graphics
文件夹中。
现在,添加world
文件夹,包括level1.txt
文件。此文件包含所有游戏对象的布局,我们将在第二十一章中进一步讨论,文件 I/O 和游戏对象工厂。
使用过滤器组织代码文件
接下来,我们将做一些新的事情。由于这个项目中的类文件比我们以前的项目多,我们将在 Visual Studio 中更加有组织。我们将创建一系列过滤器。这些是我们用来创建文件结构的逻辑组织者。这将使我们能够以更有组织的方式查看所有头文件和源文件。
右键单击Engine
。我们将把所有核心头文件添加到这个过滤器中。
右键单击FileIO
。我们将添加所有读取level1.txt
的文件,以及一些支持类。
在GameObjects
中创建另一个新的过滤器。所有与所有游戏对象相关的文件,包括GameObject
类和所有与Component
类相关的头文件,都将放在这里。
添加另一个名为Screens
的过滤器。右键单击Select
。现在,在Game
中创建另一个过滤器。我们将所有Screen
、InputHandler
和UIPanel
的派生版本放置在Game或Select(根据需要)中,并将所有基类放置在Screens中。
现在,重复创建过滤器的前几步,以在源文件文件夹中创建完全相同的结构。你现在应该有一个如下所示的解决方案资源管理器布局:
注意,前面的布局只是为了我们的组织利益;它对代码或最终游戏没有影响。实际上,如果你使用操作系统的文件浏览器查看Space Invaders ++
文件夹,你会看到没有额外的文件夹。随着我们在这个项目中前进并添加新的类,我们将它们添加到特定的过滤器中,以使它们更有组织和更整洁。
添加一个 DevelopState 文件
为了将调试数据输出到控制台,我们将创建DevelopState
类,它除了定义debuggingOnConsole
之外不做任何事情。
在Header Files/Engine
过滤器中创建DevelopState.h
文件,并添加以下代码:
#pragma once
#define debuggingOnConsole
class DevelopState {};
当游戏运行正常时,我们可以取消注释 #define debuggingOnConsole
,当我们遇到无法解释的崩溃时,我们可以重新注释它。如果我们然后在代码的各个部分添加断言,我们就可以看到这些部分是否导致游戏崩溃。
编写 SpaceInvaders ++.cpp
接下来,将我们在创建项目时自动生成的 SpaceInvaders ++.cpp
文件拖放到 Source Files/Engine
过滤器中。这不是必需的——只是为了保持整洁。此文件是游戏的入口点,因此是一个核心文件,尽管它非常短。
编辑 SpaceInvaders ++.cpp
,使其只包含以下代码:
#include "GameEngine.h"
int main()
{
GameEngine m_GameEngine;
m_GameEngine.run();
return 0;
}
前面的代码创建了一个 GameEngine
实例并调用其 run
函数。直到我们编写 GameEngine
类之前,都会出现错误。我们将在下一部分完成这项工作。注意,在整个项目中,通常会有一个、更多甚至许多错误。这是由于类之间的相互依赖性。我通常会提到错误以及何时处理它们,但可能不会提到每一个。在本章结束时,我们将有一个没有错误、可执行的项目,但之后,它将需要直到 第二十二章,使用游戏对象和构建游戏,直到项目再次没有错误且可执行。
编写 GameEngine 类
在 Header Files/Engine
过滤器中创建一个新的头文件,命名为 GameEngine.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include "ScreenManager.h"
#include "SoundEngine.h"
using namespace sf;
class GameEngine {
private:
Clock m_Clock;
Time m_DT;
RenderWindow m_Window;
unique_ptr<ScreenManager> m_ScreenManager;
float m_FPS = 0;
Vector2f m_Resolution;
void handleInput();
void update();
void draw();
public:
SoundEngine m_SoundEngine;
GameEngine();
void run();
};
研究前面的代码以熟悉它。新的是我们第一次看到智能指针的实际应用。我们有一个 ScreenManager
类型的唯一指针。这意味着这个指针不会被传递给其他任何类,但如果它被传递,则所有权也会传递。
除了智能指针之外,我们之前都没有见过。有一个 Clock
实例,一个 Time
实例,一个 RenderWindow
实例,以及用于跟踪帧率和屏幕分辨率的变量。此外,我们还有处理输入、更新和绘制每一帧的函数。这也不是什么新东西。然而,我们在这些函数中所做的工作将是新的。我们还有一个 SoundEngine
实例,它将几乎与我们处理其他项目中的声音的方式相同。我们还有一个公开的 run
函数,它将启动所有私有函数。
出现错误是因为我们需要实现 ScreenManager
和 SoundEngine
类。我们很快就会实现它们。
在 Source Files/Engine
过滤器中创建一个新的源文件,命名为 GameEngine.cpp
,并添加以下代码:
#include "GameEngine.h"
GameEngine::GameEngine()
{
m_Resolution.x = VideoMode::getDesktopMode().width;
m_Resolution.y = VideoMode::getDesktopMode().height;
m_Window.create(VideoMode(m_Resolution.x, m_Resolution.y),
"Space Invaders++", Style::Fullscreen);
m_ScreenManager = unique_ptr<ScreenManager>(new ScreenManager(
Vector2i(m_Resolution.x, m_Resolution.y)));
}
void GameEngine::run()
{
while (m_Window.isOpen())
{
m_DT = m_Clock.restart();
m_FPS = m_DT.asSeconds();
handleInput();
update();
draw();
}
}
void GameEngine::handleInput()
{
m_ScreenManager->handleInput(m_Window);
}
void GameEngine::update()
{
m_ScreenManager->update(m_FPS);
}
void GameEngine::draw()
{
m_Window.clear(Color::Black);
m_ScreenManager->draw(m_Window);
m_Window.display();
}
在 GameEngine
构造函数中,使用 new
初始化 RenderWindow
实例,并使用 new
初始化指向 ScreenManager
实例的唯一智能指针,将分辨率传递给 ScreenManager
构造函数。
重要提示
这是一种调用 make_unique
函数的替代方法。
run
函数看起来应该非常熟悉;它重新启动时钟并存储时间,就像我们迄今为止在每一个项目中做的那样。然后调用 handleInput
、update
和 draw
函数。
在 handleInput
函数中,调用的是 ScreenManager
实例的 handleInput
函数。在 update
函数中,调用的是 ScreenManger
实例的 update
函数。最后,在 draw
函数中,清除 RenderWindow
,调用 ScreenManager
实例的 draw
函数,并显示 RenderWindow
实例的内容。
我们已经成功地将处理输入、更新和绘制每一帧的完全责任交给了 ScreenManager
类。正如我们将在 编码 ScreenManager 部分看到的那样,ScreenManager
类将进一步将这些任务的责任委托给从 Screen
类派生出的适当类。
与相关的 GameEngine.h
头文件一样,存在错误,因为我们需要实现 ScreenManager
和 SoundEngine
类。
编码 SoundEngine 类
在 Header Files/Engine
过滤器中创建一个新的头文件,命名为 SoundEngine.h
,并添加以下代码:
#pragma once
#ifndef SOUND_ENGINE_H
#define SOUND_ENGINE_H
#include <SFML/Audio.hpp>
using namespace sf;
class SoundEngine
{
private:
SoundBuffer m_ShootBuffer;
SoundBuffer m_PlayerExplodeBuffer;
SoundBuffer m_InvaderExplodeBuffer;
SoundBuffer m_ClickBuffer;
Sound m_ShootSound;
Sound m_PlayerExplodeSound;
Sound m_InvaderExplodeSound;
Sound m_UhSound;
Sound m_OhSound;
Sound m_ClickSound;
public:
SoundEngine();
static void playShoot();
static void playPlayerExplode();
static void playInvaderExplode();
static void playClick();
static SoundEngine* m_s_Instance;
};
#endif
在 Source Files/Engine
过滤器中创建一个新的源文件,命名为 SoundEngine.cpp
,并添加以下代码:
#include <SFML/Audio.hpp>
#include <assert.h>
#include "SoundEngine.h"
using namespace std;
using namespace sf;
SoundEngine* SoundEngine::m_s_Instance = nullptr;
SoundEngine::SoundEngine()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
// Load the sound into the buffers
m_ShootBuffer.loadFromFile("sound/shoot.ogg");
m_PlayerExplodeBuffer.loadFromFile("sound/playerexplode.ogg");
m_InvaderExplodeBuffer.loadFromFile("sound/invaderexplode.ogg");
m_ClickBuffer.loadFromFile("sound/click.ogg");
// Associate the sounds with the buffers
m_ShootSound.setBuffer(m_ShootBuffer);
m_PlayerExplodeSound.setBuffer(m_PlayerExplodeBuffer);
m_InvaderExplodeSound.setBuffer(m_InvaderExplodeBuffer);
m_ClickSound.setBuffer(m_ClickBuffer);
}
void SoundEngine::playShoot()
{
m_s_Instance->m_ShootSound.play();
}
void SoundEngine::playPlayerExplode()
{
m_s_Instance->m_PlayerExplodeSound.play();
}
void SoundEngine::playInvaderExplode()
{
m_s_Instance->m_InvaderExplodeSound.play();
}
void SoundEngine::playClick()
{
m_s_Instance->m_ClickSound.play();
}
SoundEngine
类使用与之前项目中 SoundManager
类完全相同的策略。事实上,SoundEngine
比较简单,因为我们没有使用空间化功能。要了解 SoundEngine
类的工作原理,请参阅 第十七章,声音空间化和 HUD。
现在,我们可以继续编写 ScreenManager
类。
编码 ScreenManager 类
在 Header Files/Engine
过滤器中创建一个新的头文件,命名为 ScreenManager.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include <map>
#include "GameScreen.h"
#include "ScreenManagerRemoteControl.h"
#include "SelectScreen.h"
//#include "LevelManager.h"
#include "BitmapStore.h"
#include <iostream>
using namespace sf;
using namespace std;
class ScreenManager : public ScreenManagerRemoteControl {
private:
map <string, unique_ptr<Screen>> m_Screens;
//LevelManager m_LevelManager;
protected:
string m_CurrentScreen = "Select";
public:
BitmapStore m_BS;
ScreenManager(Vector2i res);
void update(float fps);
void draw(RenderWindow& window);
void handleInput(RenderWindow& window);
/****************************************************
*****************************************************
From ScreenManagerRemoteControl interface
*****************************************************
*****************************************************/
void ScreenManagerRemoteControl::
SwitchScreens(string screenToSwitchTo)
{
m_CurrentScreen = "" + screenToSwitchTo;
m_Screens[m_CurrentScreen]->initialise();
}
void ScreenManagerRemoteControl::
loadLevelInPlayMode(string screenToLoad)
{
//m_LevelManager.getGameObjects().clear();
//m_LevelManager.
//loadGameObjectsForPlayMode(screenToLoad);
SwitchScreens("Game");
}
//vector<GameObject>&
//ScreenManagerRemoteControl::getGameObjects()
//{
//return m_LevelManager.getGameObjects();
//}
//GameObjectSharer& shareGameObjectSharer()
//{
//return m_LevelManager;
//}
};
在之前的代码中,有一些 #include
语句和一些被注释掉的函数。这是因为我们将在 第二十一章,文件 I/O 和游戏对象工厂 中编写 LevelManager
类。
下一个需要注意的事情是,ScreenManager
继承自 ScreenManagerRemoteControl
。关于这个类,我们稍后会详细介绍。
我们已经编写了一个 map
,其键值对为 string
和指向 Screen
的唯一指针。这将允许我们通过使用相应的 string
来获取特定 Screen
实例的功能。接下来,我们声明一个名为 m_CurrentScreen
的 string
并将其初始化为 Select
。
接下来,我们声明一个名为 m_BS
的 BitmapStore
实例。这将是我们之前在两个项目中看到的 TextureHolder
类的略微修改版本。我们将在下一个项目中编写 BitmapStore
类。
注意到 ScreenManager
的构造函数接受一个 Vector2i
实例,这是我们初始化 GameEngine
类中的 ScreenManager
实例时所期望的。
接下来是update
、draw
和handleInput
函数原型,这些函数由GameEngine
类调用。
接下来的两个函数是最有趣的。注意,它们来自ScreenManagerRemoteControl
类,ScreenManager
类继承自该类。这些是ScreenManagerRemoteControl
中的纯虚函数,我们这样做是为了能够与其他类共享ScreenManager
类的一些功能。我们将在几个部分中编写ScreenManagerRemoteControl
类。记住,当你从具有纯虚函数的类继承时,如果你想创建一个实例,你必须实现这些函数。此外,实现应该包含在类声明的同一文件中。有四个函数,其中两个目前已被注释掉。两个感兴趣的函数是SwitchScreens
和loadLevelInPlayMode
。
SwitchScreen
函数更改m_CurrentScreen
的值,而loadLevelInPlayMode
函数有一些暂时注释掉的代码和一行活动代码,该代码调用SwitchScreens
并传递Game
的值。
让我们继续查看ScreenManager.cpp
文件,以便我们可以查看所有函数定义。
在Source Files/Engine
筛选器中创建一个名为ScreenManager.cpp
的新源文件,并添加以下代码:
#include "ScreenManager.h"
ScreenManager::ScreenManager(Vector2i res)
{
m_Screens["Game"] = unique_ptr<GameScreen>(
new GameScreen(this, res));
m_Screens["Select"] = unique_ptr<SelectScreen>(
new SelectScreen(this, res));
}
void ScreenManager::handleInput(RenderWindow& window)
{
m_Screens[m_CurrentScreen]->handleInput(window);
}
void ScreenManager::update(float fps)
{
m_Screens[m_CurrentScreen]->update(fps);
}
void ScreenManager::draw(RenderWindow& window)
{
m_Screens[m_CurrentScreen]->draw(window);
}
在前面的代码中,构造函数向map
实例添加了两个Screen
实例 - 首先,一个键为"Game"
的GameScreen
实例,然后是一个键为"Select"
的SelectScreen
实例。三个函数handleInput
、update
和draw
使用当前屏幕,使用相应的Screen
实例,并调用其handleInput
、update
和draw
函数。
当游戏第一次执行时,将调用SelectScreen
中的这些函数版本,但如果调用了ChangeScreen
或loadLevelInPlayMode
函数,则可以在map
上调用GameScreen
实例的handleInput
、update
和draw
。你可以将尽可能多的不同类型的Screen
实例添加到map
中。然而,我建议你在开始进行自定义或开始自己的游戏之前,先完成 Space Invaders ++项目。
编写BitmapStore
类
在Header Files/Engine
筛选器中创建一个名为BitmapStore.h
的新头文件,并添加以下代码:
#pragma once
#ifndef BITMAP_STORE_H
#define BITMAP_STORE_H
#include <SFML/Graphics.hpp>
#include <map>
class BitmapStore
{
private:
std::map<std::string, sf::Texture> m_BitmapsMap;
static BitmapStore* m_s_Instance;
public:
BitmapStore();
static sf::Texture& getBitmap(std::string const& filename);
static void addBitmap(std::string const& filename);
};
#endif
在Source Files/Engine
筛选器中创建一个名为BitmapStore.cpp
的新源文件,并添加以下代码:
#include "BitmapStore.h"
#include <assert.h>
using namespace sf;
using namespace std;
BitmapStore* BitmapStore::m_s_Instance = nullptr;
BitmapStore::BitmapStore()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
void BitmapStore::addBitmap(std::string const& filename)
{
// Get a reference to m_Textures using m_S_Instance
auto& bitmapsMap = m_s_Instance->m_BitmapsMap;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in file name
auto keyValuePair = bitmapsMap.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// No match found so save the texture in the map
if (keyValuePair == bitmapsMap.end())
{
// Create a new key value pair using the filename
auto& texture = bitmapsMap[filename];
// Load the texture from file in the usual way
texture.loadFromFile(filename);
}
}
sf::Texture& BitmapStore::getBitmap(std::string const& filename)
{
// Get a reference to m_Textures using m_S_Instance
auto& m = m_s_Instance->m_BitmapsMap;
// auto is the equivalent of map<string, Texture>
// Create an iterator to hold a key-value-pair (kvp)
// and search for the required kvp
// using the passed in file name
auto keyValuePair = m.find(filename);
// auto is equivalent of map<string, Texture>::iterator
// Did we find a match?
if (keyValuePair != m.end())
{
return keyValuePair->second;
}
else
{
#ifdef debuggingOnConsole
cout <<
"BitmapStore::getBitmap()Texture not found Crrrashh!"
<< endl;
#endif
return keyValuePair->second;
}
}
前面的代码几乎是从前两个项目中的BitmapStore
类复制粘贴过来的,除了最后的else
块。在最后的else
块中,我们第一次使用 C++断言将请求的纹理名称输出到控制台,如果找不到纹理。这仅在debuggingOnConsole
被定义时发生。请注意,这也可能导致游戏崩溃。
编写 ScreenManagerRemoteControl 类的代码
在 Header Files/Screens
过滤器中创建一个新的头文件,命名为 ScreenManagerRemoteControl.h
,并添加以下代码:
#pragma once
#include <string>
#include <vector>
//#include "GameObject.h"
//#include "GameObjectSharer.h"
using namespace std;
class ScreenManagerRemoteControl
{
public:
virtual void SwitchScreens(string screenToSwitchTo) = 0;
virtual void loadLevelInPlayMode(string screenToLoad) = 0;
//virtual vector<GameObject>& getGameObjects() = 0;
//virtual GameObjectSharer& shareGameObjectSharer() = 0;
};
注意在之前的代码中,有一些 #include
语句和一些被注释掉的函数。这是因为我们直到下一章才不会编写 GameObject
和 GameObjectSharer
类。
剩余的代码是为与我们在 ScreenManager.h
文件中之前看到的定义相匹配的原型设计的。正如你所期待的,所有函数都是纯虚函数,因此我们必须为任何我们希望有实例的类实现这些函数。
在 Source Files/Screens
过滤器中创建一个新的源文件,命名为 ScreenManagerRemoteControl.cpp
,并添加以下代码:
/*********************************
******THIS IS AN INTERFACE********
*********************************/
这个代码文件是空的,因为所有代码都在 .h
文件中。实际上,你不需要创建这个文件,但我总是觉得这是一个方便的提醒,以防我忘记所有类的函数都是纯虚函数,从而浪费时间寻找不存在的 .cpp
文件。
我们现在在哪里?
到目前为止,代码中唯一剩余的错误是关于 SelectScreen
类和 GameScreen
类的错误。要消除这些错误并得到一个可运行的程序,需要相当多的工作。原因在于 SelectScreen
和 GameScreen
都是从 Screen
派生的,而 Screen
类本身也依赖于 InputHandler
、UIPanel
和 Button
。我们将在下一部分处理它们。
编写 Screen 类及其依赖项的代码
我们现在要做的就是编写所有与屏幕相关的类。此外,我们游戏中的每个屏幕都将有这些类的特定实现。
接下来,我们将编写所有基类;Screen
、InputHandler
、UIPanel
和 Button
。随后,我们将实现这些类的 SelectScreen
派生类的完整实现和 GameScreen
派生类的部分实现。到这时,我们就能运行游戏并看到我们的屏幕、UI 面板和按钮的实际效果,同时也能在屏幕之间切换。在下一章中,我们将正确处理游戏并实现 GameObject
和 LevelManager
。在 第二十二章,使用游戏对象和构建游戏,我们将看到我们如何在 GameScreen
类中使用它们。
编写 Button 类
在 Header Files/Screens
过滤器中创建一个新的头文件,命名为 Button.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Button
{
private:
RectangleShape m_Button;
Text m_ButtonText;
Font m_Font;
public:
std::string m_Text;
FloatRect m_Collider;
Button(Vector2f position,
float width, float height,
int red, int green, int blue,
std::string text);
void draw(RenderWindow& window);
};
如您从前面的代码中看到的,按钮将通过 SFML 的RectangleShape
实例和Text
实例来视觉表示。还要注意,有一个名为m_Collider
的FloatRect
实例,它将用于检测按钮上的鼠标点击。构造函数将接收参数来配置按钮的位置、大小、颜色和文本。按钮将在游戏循环的每一帧中绘制自己,并且有一个draw
函数,它接收一个RenderWindow
引用来实现这一点。
在Source Files/Screens
过滤器中创建一个新的源文件,命名为Button.cpp
,并添加以下代码:
#include "Button.h"
Button::Button(Vector2f position,
float width, float height,
int red, int green, int blue,
std::string text)
{
m_Button.setPosition(position);
m_Button.setFillColor(sf::Color(red, green, blue));
m_Button.setSize(Vector2f(width, height));
m_Text = "" + text;
float textPaddingX = width /10;
float textPaddingY= height / 10;
m_ButtonText.setCharacterSize(height * .7f);
m_ButtonText.setString(text);
m_Font.loadFromFile("fonts/Roboto-Bold.ttf");
m_ButtonText.setFont(m_Font);
m_ButtonText.setPosition(Vector2f((position.x + textPaddingX),
(position.y + textPaddingY)));
m_Collider = FloatRect(position, Vector2f(width, height));
}
void Button::draw(RenderWindow& window)
{
window.draw(m_Button);
window.draw(m_ButtonText);
}
大部分操作都在构造函数中完成,而且我们在所有其他项目中已经多次见过类似的操作。按钮被准备用来绘制,使用构造函数传入的所有值。
draw
函数使用RenderWindow
引用在之前配置的RectangleShape
实例上绘制之前配置的Text
实例。
编写 UIPanel 类
在Header Files/Screens
过滤器中创建一个新的头文件,命名为UIPanel.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include "Button.h"
using namespace std;
class UIPanel {
private:
RectangleShape m_UIPanel;
bool m_Hidden = false;
vector<shared_ptr<Button>> m_Buttons;
protected:
float m_ButtonWidth = 0;
float m_ButtonHeight = 0;
float m_ButtonPadding = 0;
Font m_Font;
Text m_Text;
void addButton(float x, float y, int width, int height,
int red, int green, int blue,
string label);
public:
View m_View;
UIPanel(Vector2i res, int x, int y,
float width, float height,
int alpha, int red, int green, int blue);
vector<shared_ptr<Button>> getButtons();
virtual void draw(RenderWindow& window);
void show();
void hide();
};
UIPanel
类的private
部分包括一个将视觉上表示面板背景的RectangleShape
,一个布尔值来跟踪面板是否当前对玩家可见,以及一个智能指针vector
来持有此面板的所有Button
实例。请注意,这些智能指针是共享类型的,这样我们就可以传递它们,并让shared_pointer
类负责计数引用并在必要时删除内存。
在protected
部分,有用于记住按钮大小和间距的成员变量,以及用于在面板上绘制文本的Text
和Font
实例。本项目中的所有面板都只有一个Text
实例,但具体的派生类可以根据需要添加额外的成员。例如,HighScoreUIPanel
类可能需要一个充满Text
实例的vector
来绘制最高分数列表。
此外还有一个addButton
函数,这个函数将调用Button
类构造函数并将实例添加到vector
中。
在public
部分,我们可以看到每个UIPanel
实例都将有自己的View
实例。这使得每个面板和屏幕都可以按自己的方式配置其View
。所有的View
实例都将被绘制并添加到RenderWindow
中,形成层。
UIPanel
构造函数接收所有必要的尺寸和颜色来配置其RectangleShape
。getButtons
函数共享Button
实例的vector
,以便其他类可以与按钮交互。例如,InputHandler
类将需要按钮来检测鼠标点击。这就是为什么我们使用了共享智能指针。
当然,draw
函数在游戏循环的每一帧都会被调用一次,并且是 virtual
的,因此它可以被派生类选择性地覆盖和定制。show
和 hide
函数将切换 m_Hidden
的值,以跟踪这个面板当前是否对玩家可见。
在 Source Files/Screens
过滤器中创建一个新的源文件,命名为 UIPanel.cpp
,并添加以下代码:
#include "UIPanel.h"
UIPanel::UIPanel(Vector2i res, int x, int y,
float width, float height,
int alpha, int red, int green, int blue)
{
m_UIPanel.setFillColor(sf::Color(red, green, blue, alpha));
// How big in pixels is the UI panel
m_UIPanel.setSize(Vector2f(width, height));
// How big in pixels is the view
m_View.setSize(Vector2f(width, height));
// Where in pixels does the center of the view focus
// This is most relevant when drawing a portion
// of the game world
// width/2, height/2 ensures it is exactly centered around the
// RectangleShape, mUIPanel
m_View.setCenter(width / 2, height / 2);
// Where in the window is the view positioned?
float viewportStartX = 1.f / (res.x / x);
float viewportStartY = 1.f / (res.y / y);
float viewportSizeX = 1.f / (res.x / width);
float viewportSizeY = 1.f / (res.y / height);
// Params from left to right
// StartX as a fraction of 1, startY as a fraction of 1
// SizeX as a fraction of 1
// SizeY as a fraction of 1
m_View.setViewport(FloatRect(viewportStartX, viewportStartY,
viewportSizeX, viewportSizeY));
}
vector<shared_ptr<Button>> UIPanel::getButtons()
{
return m_Buttons;
}
void UIPanel::addButton(float x, float y,
int width, int height,
int red, int green, int blue,
string label)
{
m_Buttons.push_back(make_shared<Button>(Vector2f(x, y),
width, height,
red, green, blue,
label));
}
void UIPanel::draw(RenderWindow & window)
{
window.setView(m_View);
if (!m_Hidden) {
window.draw(m_UIPanel);
for (auto it = m_Buttons.begin();
it != m_Buttons.end(); ++it)
{
(*it)->draw(window);
}
}
}
void UIPanel::show()
{
m_Hidden = false;
}
void UIPanel::hide()
{
m_Hidden = true;
}
在构造函数中,RectangleShape
实例被缩放、着色和定位。View
实例也被缩放到面板的大小。View
类的 setViewport
函数与一些额外的计算一起使用,以确保 View
占据屏幕的正确比例,因此在不同分辨率的屏幕上看起来大致相同。
getButtons
函数简单地返回按钮的 vector
给调用代码。addButtons
函数使用 make_shared
函数在堆上分配新的 Button
实例,并将它们放入 vector
中。
draw
函数使用 setView
函数来使这个面板的特定 View
实例成为被绘制的对象。接下来是 RectangleShape
,它表示这个面板被绘制。然后,vector
中的每个按钮都会被循环遍历并绘制在 RectangleShape
上。所有这些绘制只有在 m_Hidden
为假时才会发生。
show
和 hide
函数允许类的用户切换 m_Hidden
。
编写 InputHandler 类
在 Header Files/Screens
过滤器中创建一个新的头文件,命名为 InputHandler.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
#include "Button.h"
#include "Screen.h"
#include "ScreenManagerRemoteControl.h"
using namespace sf;
using namespace std;
class Screen;
class InputHandler
{
private:
Screen* m_ParentScreen;
vector<shared_ptr<Button>> m_Buttons;
View* m_PointerToUIPanelView;
ScreenManagerRemoteControl* m_ScreenManagerRemoteControl;
public:
void initialiseInputHandler(
ScreenManagerRemoteControl* sw,
vector<shared_ptr<Button>>,
View* pointerToUIView,
Screen* parentScreen);
void handleInput(RenderWindow& window, Event& event);
virtual void handleGamepad();
virtual void handleKeyPressed(Event& event,
RenderWindow& window);
virtual void handleKeyReleased(Event& event,
RenderWindow& window);
virtual void handleLeftClick(string& buttonInteractedWith,
RenderWindow& window);
View* getPointerToUIView();
ScreenManagerRemoteControl*
getPointerToScreenManagerRemoteControl();
Screen* getmParentScreen();
};
这个文件中有一个错误,因为 Screen
类还不存在。
首先,研究这个头文件中的 private
部分。每个 InputHandler
实例都将持有指向包含它的屏幕的指针。在项目继续进行的过程中,我们将遇到一些情况,这将是有用的。还有一个指向 Button
实例的 vector
的共享智能指针。这些是我们在刚刚编写的 UIPanel
中的相同的 Button
实例。每个派生的 UIPanel
都将有一个匹配的派生 InputHandler
,它与它共享一个按钮的 vector
。
InputHandler
类还持有 UIPanel
中的 View
实例的指针。当我们编写 InputHandler.cpp
中的函数定义时,我们将看到如何获取这个指针以及它如何有用。
还有一个指向 ScreenManagerRemoteControl
的指针。记得从 ScreenManager
类中,我们已经实现了 ScreenManagerRemoteControl
的一些函数。这将使我们能够访问 SwitchScreen
等函数。当考虑到 InputHandler
是我们将检测按钮点击的类时,这非常有用。当然,我们需要看看我们如何初始化这个指针,使其可使用。我们将在 InputHandler.cpp
文件中很快看到。
在 public
部分,有一个 initialiseInputHandler
函数。这就是我们刚才提到的私有成员将被准备用于使用的位置。看看参数;它们与私有成员的类型完全匹配。
接下来是 handleInput
函数。请记住,这个函数由 GameEngine
类每帧调用一次;ScreenManager
在当前屏幕上调用它,而 Screen
类(稍后编码),依次调用它所持有的所有 InputHandler
实例。它接收一个 RenderWindow
和一个 Event
实例。
接下来,有四个 virtual
函数,每个派生自 InputHandler
类,如果需要,它可以选择重写。它们如下所示:
-
handleGamepad
-
handleKeyPressed
-
handleKeyReleased
-
handleLeftClick
如我们很快将看到的,在 InputHandler.cpp
文件中,handleInput
函数将循环遍历 Event
中的数据,就像我们之前经常做的那样。但是,然后,它不会像我们过去那样直接处理所有事件,而是将响应委托给四个虚拟函数之一。派生类将只接收它们决定要处理的事件和数据。在 InputHandler.cpp
文件中提供了四个虚拟函数的默认和空定义。
getPointerToUIView
函数将返回指向这个 InputHandler
实例持有的面板 View
的指针。我们很快就会看到,我们需要 View
来在按钮上执行鼠标点击碰撞检测。
getPointerToScreenManagerRemoteControl
和 getmParentScreen
返回指向由函数名称建议的成员变量的指针。
重要提示
注意,如果你将私有数据设置为 protected
,那么派生的 InputHandler
类可以不通过我们刚才讨论的函数访问数据。当项目完成后,你可以随意回顾这一部分,并根据需要更改它。
现在,我们可以编写所有函数的定义。
在 Source Files/Screens
过滤器中创建一个新的源文件,命名为 InputHandler.cpp
,并添加以下代码:
#include <sstream>
#include "InputHandler.h"
using namespace sf;
using namespace std;
void InputHandler::initialiseInputHandler(
ScreenManagerRemoteControl* sw,
vector<shared_ptr<Button>> buttons,
View* pointerToUIView,
Screen* parentScreen)
{
m_ScreenManagerRemoteControl = sw;
m_Buttons = buttons;
m_PointerToUIPanelView = pointerToUIView;
m_ParentScreen = parentScreen;
}
void InputHandler::handleInput(RenderWindow& window,
Event& event)
{
// Handle any key presses
if (event.type == Event::KeyPressed)
{
handleKeyPressed(event, window);
}
if (event.type == Event::KeyReleased)
{
handleKeyReleased(event, window);
}
// Handle any left mouse click released
if (event.type == Event::MouseButtonReleased)
{
auto end = m_Buttons.end();
for (auto i = m_Buttons.begin();
i != end;
++i) {
if ((*i)->m_Collider.contains(
window.mapPixelToCoords(Mouse::getPosition(),
(*getPointerToUIView()))))
{
// Capture the text of the button that was interacted
// with and pass it to the specialised version
// of this class if implemented
handleLeftClick((*i)->m_Text, window);
break;
}
}
}
handleGamepad();
}
void InputHandler::handleGamepad()
{}// Do nothing unless handled by a derived class
void InputHandler::handleKeyPressed(Event& event,
RenderWindow& window)
{}// Do nothing unless handled by a derived class
void InputHandler::handleKeyReleased(Event& event,
RenderWindow& window)
{}// Do nothing unless handled by a derived class
void InputHandler::handleLeftClick(std::
string& buttonInteractedWith,
RenderWindow& window)
{}// Do nothing unless handled by a derived class
View* InputHandler::getPointerToUIView()
{
return m_PointerToUIPanelView;
}
ScreenManagerRemoteControl*
InputHandler::getPointerToScreenManagerRemoteControl()
{
return m_ScreenManagerRemoteControl;
}
Screen* InputHandler::getmParentScreen() {
return m_ParentScreen;
}
initialiseInputHandler
函数初始化私有数据,正如我们之前讨论的那样,四个 virtual
函数是空的,正如预期的那样,并且获取函数返回指向私有成员的指针,就像我们说的那样。
有趣的是 handleInput
函数的定义,让我们来详细看看它。
这里有一系列 if
语句,这些语句应该与之前的项目的代码看起来很熟悉。每个 if
语句测试不同类型的事件,例如按键按下或按键释放。然而,不是处理事件,而是调用适当的 virtual
函数。如果派生的 InputHandler
类重写了 virtual
函数,它将接收数据并处理事件。如果没有,则调用空的默认函数定义,什么也不会发生。
当MouseButtonReleased
事件发生时,vector
中的每个Button
实例都会被测试,以查看点击是否发生在按钮内。这是通过在每个按钮的碰撞器上使用contains
函数并传入鼠标点击的位置来实现的。请注意,按钮坐标是相对于面板的View
而不是屏幕坐标。因此,使用mapPixelToCoords
函数将鼠标点击的屏幕坐标转换为View
的对应坐标。
当检测到碰撞时,会调用handleLeftClick virtual
函数,并将按钮上的文本传递进去。派生的InputHandler
类将根据按钮上的文本处理按钮点击发生的情况。
handleInput
函数中的最后一行代码调用了最后的virtual
函数handleGamepad
。任何实现此函数的派生InputHandler
类都将有机会通过游戏手柄对玩家的动作做出响应。在这个项目中,只有GameInputHandler
会关注游戏手柄的行为。如果你想,你可以修改项目以允许玩家使用游戏手柄导航其他屏幕的菜单。
编写Screen
类代码
在Header Files/Screens
过滤器中创建一个新的头文件,命名为Screen.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
#include "InputHandler.h"
#include "UIPanel.h"
#include "ScreenManagerRemoteControl.h"
class InputHandler;
class Screen {
private:
vector<shared_ptr<InputHandler>> m_InputHandlers;
vector<unique_ptr<UIPanel>> m_Panels;
protected:
void addPanel(unique_ptr<UIPanel> p,
ScreenManagerRemoteControl* smrc,
shared_ptr<InputHandler> ih);
public:
virtual void initialise();
void virtual update(float fps);
void virtual draw(RenderWindow& window);
void handleInput(RenderWindow& window);
View m_View;
};
在前面代码的private
部分,有一个指向InputHandler
实例的共享智能指针向量。这就是我们将存储所有派生InputHandler
实例的地方。SelectScreen
实际上只有一个InputHandler
,而GameScreen
将有两个,但你喜欢有多少就可以有多少。考虑一下,例如一个假设的设置屏幕,你可能会有图形、声音、控制器、游戏玩法等选项。每个选项都可以点击以显示一个相关的UIPanel
实例和InputHandler
。因此,我们本可以避免在这个项目中使用vector
,但任何重大的项目最终几乎肯定都需要使用vector
。智能指针是共享类型的,这表明我们将在某个时候通过函数传递内容。
下一个成员是一个指向UIPanel
实例的唯一智能指针向量。这就是所有派生的UIPanel
实例将去的地方。唯一指针的类型表明我们不会共享指针;如果我们共享,我们将不得不转移责任。
在受保护的区域是addPanel
函数,这是Screen
传递所有新UIPanel
实例的详细信息的地方,包括其相关的InputHandler
。注意接收ScreenManagerRemoteControl
指针的参数;记住这是传递给InputHandler
所必需的。
也有一个 initialise
函数,我们很快就会看到它的用途。最后的三个函数是 virtual
函数,即 update
、draw
和 handleInput
,派生的 Screen
类可以根据需要覆盖这些函数。
最后,注意一下 View
实例。每个 Screen
实例也将有自己的 View
实例来绘制,就像每个 UIPanel
一样。
让我们来看看我们刚刚讨论过的函数的实现。
在 Source Files/Screens
过滤器中创建一个新的源文件,命名为 Screen.cpp
,并添加以下代码:
#include "Screen.h"
void Screen::initialise(){}
void Screen::addPanel(unique_ptr<UIPanel> uip,
ScreenManagerRemoteControl* smrc,
shared_ptr<InputHandler> ih)
{
ih->initialiseInputHandler(smrc,
uip->getButtons(), &uip->m_View, this);
// Use move() because otherwise
// the vector has a COPY which is not allowed
m_Panels.push_back(move(uip));
m_InputHandlers.push_back(ih);
}
void Screen::handleInput(RenderWindow& window)
{
Event event;
auto itr = m_InputHandlers.begin();
auto end = m_InputHandlers.end();
while (window.pollEvent(event))
{
for (itr;
itr != end;
++itr)
{
(*itr)->handleInput(window, event);
}
}
}
void Screen::update(float fps){}
void Screen::draw(RenderWindow& window)
{
auto itr = m_Panels.begin();
auto end = m_Panels.end();
for (itr;
itr != end;
++itr)
{
(*itr)->draw(window);
}
}
initialise
函数是空的。它被设计成可以被覆盖。
addPanel
函数,正如我们已知的,存储传递给它的 InputHandler
和 UIPanel
实例。当一个 InputHandler
被传递进来时,initialiseInputHandler
函数被调用,并传递了三样东西。首先是 Button
实例的 vector
,接下来是相关 UIPanel
实例的 View
实例,第三是 this
参数。在当前上下文中,this
是指向 Screen
实例本身的指针。为什么不参考 InputHandler
类来验证这些参数是否正确以及它们会发生什么?
接下来,将面板和输入处理器添加到适当的 vector
中。然而,如果你仔细观察,会发生一些有趣的事情。再次看看添加名为 uip
的 UIPanel
实例到 m_Panels
向量的那行代码:
m_Panels.push_back(move(uip));
传递给 push_back
的参数被包裹在一个对 move
的调用中。这把对 UIPanel
在 vector
中的唯一指针的责任转移给了 UIPanel
。从这一点开始,任何尝试使用 uip
的操作都将导致读取访问违规,因为 uip
现在是一个空指针。然而,m_Panels
中的指针是有效的。你可能认为这比使用常规指针并确定删除位置要简单。
handleInput
函数遍历每个事件,依次将它们传递给每个 InputHandler
。
update
函数在基类中没有功能,是空的。
draw
函数遍历每个 UIPanel
实例,并调用它们的 draw
函数。
现在,我们已经准备好编写所有派生类了。我们将从选择屏幕(SelectScreen
)开始,然后继续到游戏屏幕(GameScreen
)。不过,我们首先会添加一个额外的快速类。
添加 WorldState.h 文件
在 Header Files/Engine
过滤器中创建一个新的头文件,命名为 WorldState.h
,并添加以下代码:
#pragma once
class WorldState
{
public:
static const int WORLD_WIDTH = 100;
static int WORLD_HEIGHT;
static int SCORE;
static int LIVES;
static int NUM_INVADERS_AT_START;
static int NUM_INVADERS;
static int WAVE_NUMBER;
};
这些变量是公共的和静态的。因此,它们将在整个项目中都是可访问的,并且保证只有一个实例。
编写选择屏幕的派生类
到目前为止,我们已经编写了代表用户界面以及将我们的游戏划分为屏幕的基本类。接下来,我们将编写它们的特定实现。记住,Space Invaders ++ 将有两个屏幕:选择屏幕和游戏屏幕。选择屏幕将由SelectScreen
类表示,并将有一个单独的UIPanel
实例、一个单独的InputHandler
实例和两个按钮。游戏屏幕将由GameScreen
类表示,它将有两个UIPanel
实例。一个被称为GameUIPanel
,将显示分数、生命值和入侵者波次。另一个被称为GameOverUIPanel
,将显示两个按钮,给玩家提供返回选择屏幕或再次游戏的选择。由于GameScreen
类由两个UIPanel
实例组成,它也将由两个InputHandler
实例组成。
编写SelectScreen
类
在Header Files/Screens/Select
过滤器中创建一个新的头文件,命名为SelectScreen.h
,并添加以下代码:
#pragma once
#include "Screen.h"
class SelectScreen : public Screen
{
private:
ScreenManagerRemoteControl* m_ScreenManagerRemoteControl;
Texture m_BackgroundTexture;
Sprite m_BackgroundSprite;
public:
SelectScreen(ScreenManagerRemoteControl* smrc, Vector2i res);
void virtual draw(RenderWindow& window);
};
SelectScreen
类从Screen
类继承。在前面的代码的private
部分中,有一个用于切换屏幕的ScreenManagerRemoteControl
指针,以及用于绘制背景的Texture
实例和Sprite
实例。
在public
部分,我们可以看到构造函数和覆盖了draw
函数的原型。SelectScreen
类不需要覆盖update
函数。
在Source Files/Screens/Select
过滤器中创建一个新的源文件,命名为SelectScreen.cpp
,并添加以下代码:
#include "SelectScreen.h"
#include "SelectUIPanel.h"
#include "SelectInputHandler.h"
SelectScreen::SelectScreen(
ScreenManagerRemoteControl* smrc, Vector2i res)
{
auto suip = make_unique<SelectUIPanel>(res);
auto sih = make_shared<SelectInputHandler>();
addPanel(move(suip), smrc, sih);
m_ScreenManagerRemoteControl = smrc;
m_BackgroundTexture.loadFromFile("graphics/background.png");
m_BackgroundSprite.setTexture(m_BackgroundTexture);
auto textureSize = m_BackgroundSprite.
getTexture()->getSize();
m_BackgroundSprite.setScale(float(
m_View.getSize().x) / textureSize.x,
float(m_View.getSize().y) / textureSize.y);
}
void SelectScreen::draw(RenderWindow& window)
{
// Change to this screen's view to draw
window.setView(m_View);
window.draw(m_BackgroundSprite);
// Draw the UIPanel view(s)
Screen::draw(window);
}
在构造函数中,到目前为止所有编码的目的开始汇聚。使用make_unique
函数创建了一个指向SelectUIPanel
实例的唯一智能指针。我们将在接下来的几个部分中编写SelectUIPanel
。接下来,使用make_shared
函数创建了一个指向SelectInputHandler
实例的共享智能指针。我们将在下一个部分编写SelectInputHandler
类。现在我们已经有了适当的UIPanel
和InputHandler
,我们可以调用addPanel
函数并将它们都传递进去。注意,在调用addPanel
时,suip
被包裹在一个调用move
的函数中。从这一点开始,任何对suip
的使用都可能导致程序崩溃,因为它现在是一个空指针,因为所有权已经移动到函数参数。记住,在Screen
类的addPanel
函数内部,当将UIPanel
的唯一指针存储在UIPanel
实例的vector
中时,所有权再次转移。
在此之后,ScreenManagerRemoteControl
指针被初始化,现在可以在需要时切换到另一个屏幕。
构造函数中的最后几行代码创建并缩放了一个使用background.png
图像的Sprite
实例,该实例将填充整个屏幕。
在draw
函数中,对setView
函数的调用使这个面板的View
实例成为绘制的目标,然后Sprite
实例被绘制到RenderWindow
实例上。
最后,在基类Screen
上调用draw
函数,它绘制所有面板及其相关按钮。在这个特定情况下,它只绘制一个面板,即SelectUIPanel
,我们将在编写完SelectInputHandler
之后立即编写它。
编写SelectInputHandler
类
在Header Files/Screens/Select
过滤器中创建一个新的头文件,命名为SelectInputHandler.h
,并添加以下代码:
#pragma once
#include "InputHandler.h"
class SelectInputHandler : public InputHandler
{
public:
void handleKeyPressed(Event& event,
RenderWindow& window) override;
void handleLeftClick(std::string& buttonInteractedWith,
RenderWindow& window) override;
};
SelectInputHandler
类继承自InputHandler
并重写了handleKeyPressed
和handleLeftClick
函数。让我们看看这些函数是如何实现的。
在Source Files/Screens/Select
过滤器中创建一个新的源文件,命名为SelectInputHandler.cpp
,并添加以下代码:
#include "SelectInputHandler.h"
#include "SoundEngine.h"
#include "WorldState.h"
#include <iostream>
int WorldState::WAVE_NUMBER;
void SelectInputHandler::handleKeyPressed(
Event& event, RenderWindow& window)
{
// Quit the game
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
}
void SelectInputHandler::handleLeftClick(
std::string& buttonInteractedWith, RenderWindow& window)
{
if (buttonInteractedWith == "Play") {
SoundEngine::playClick();
WorldState::WAVE_NUMBER = 0;
getPointerToScreenManagerRemoteControl()
->loadLevelInPlayMode("level1");
}
if (buttonInteractedWith == "Quit") {
SoundEngine::playClick();
window.close();
}
}
handleKeyPressed
函数仅与一个键盘键交互。当按下Esc键时,游戏退出。
在handleLeftClick
函数中,有两个if
语句。记住,InputHandler
类的handleInputFunction
传递了被点击按钮的文本以及RenderWindow
的引用。如果WAVE_NUMBER
变量设置为零,并且ScreenManagerRemoteControl
指针调用loadLevelInPlayMode
函数。loadLevelInPlayMode
函数的定义在ScreenManagerClass
中。最终,这个函数确实会从传入的文件名中加载一个关卡,但现在,它只是将屏幕切换到游戏屏幕。
如果点击了退出按钮,则游戏退出。
重要提示
在这个阶段,尽管包含了WorldState.h
,但在使用WorldState::WaveNumber
时可能会出现错误。这是正常的;这是由于 Visual Studio 解析类时的顺序造成的。当我们添加所有使用WorldState.h
的游戏屏幕相关类时,这个文件在解析之前,错误就会消失。
让我们编写SelectUIPanel
。然后,我们可以继续编写GameScreen
类。
编写SelectUIPanel
类
在Header Files/Screens/Select
过滤器中创建一个新的头文件,命名为SelectUIPanel.h
,并添加以下代码:
#pragma once
#include "UIPanel.h"
class SelectUIPanel : public UIPanel
{
private:
void initialiseButtons();
public:
SelectUIPanel(Vector2i res);
void virtual draw(RenderWindow& window);
};
SelectUIPanel
类继承自UIPanel
并重写了draw
函数。在上面的头文件中,你还可以看到一个名为initialiseButtons
的函数,以及一个构造函数。让我们编写这些定义。
在源文件Files/Screens/Select
过滤器中创建一个新的源文件,命名为SelectUIPanel.cpp
,并添加以下代码:
#include "SelectUIPanel.h"
#include <iostream>
SelectUIPanel::SelectUIPanel(Vector2i res) :
// Create a new UIPanel
// by calling the super-class constructor
UIPanel(res,
(res.x / 10) * 2, // Start 2/10 across
res.y / 3, // 1/3 of the resolution from the top
(res.x / 10) * 6, // as wide as 6/10 of the resolution
res.y / 3, // and as tall as 1/3 of the resolution
50, 255, 255, 255) // a, r, g, b
{
m_ButtonWidth = res.x / 20;
m_ButtonHeight = res.y / 20;
m_ButtonPadding = res.x / 100;
m_Text.setFillColor(sf::Color(0, 255, 0, 255));
m_Text.setString("SPACE INVADERS ++");
//https://www.dafont.com/roboto.font
m_Font.loadFromFile("fonts/Roboto-Bold.ttf");
m_Text.setFont(m_Font);
m_Text.setPosition(Vector2f(m_ButtonPadding,
m_ButtonHeight + (m_ButtonPadding * 2)));
m_Text.setCharacterSize(160);
initialiseButtons();
}
void SelectUIPanel::initialiseButtons()
{
// Buttons are positioned relative to the top left
// corner of the UI panel(m_View in UIPanel)
addButton(m_ButtonPadding,
m_ButtonPadding,
m_ButtonWidth,
m_ButtonHeight,
0, 255, 0,
"Play");
addButton(m_ButtonWidth + (m_ButtonPadding * 2),
m_ButtonPadding,
m_ButtonWidth,
m_ButtonHeight,
255, 0, 0,
"Quit");
}
void SelectUIPanel::draw(RenderWindow& window)
{
show();
UIPanel::draw(window);
window.draw(m_Text);
}
构造函数接收屏幕分辨率,并立即使用该数据调用超类构造函数。通过使用存储在res
中的值进行计算,计算了面板的起始位置和大小。重要的是这个计算在这里进行,而不是在UIPanel
类中进行,因为每个UIPanel
的大小和位置都不同。如果你对每个特定计算的效果感兴趣,请查看前面代码中的注释。颜色也通过 alpha、红色、绿色和蓝色值传递。
接下来,初始化了基类中的成员变量,这些变量决定了按钮的大小和间距。20
这个值只是一个任意值,它有效,但重要的是所有值都是基于屏幕分辨率的,因此它们将在不同的屏幕分辨率上很好地缩放。
接下来的几行代码准备了一个Text
实例,以便在绘制函数中显示。最后,在构造函数中调用了initialiseButtons
函数。
在initialiseButtons
函数中,调用了addButton
函数两次,创建了一个上面写着“Play”的绿色按钮和一个上面写着“Quit”的红色按钮。
由于使用了WorldState.h
文件,可能会有一些错误。这些错误可以忽略,因为随着我们继续编写接下来的几个类,它们会自行纠正。
现在,我们可以编写所有与游戏界面相关的类。
为游戏界面编写派生类
所有这些类的结构都与选择界面相关的类相同。我一定会指出它们的不同之处。大多数重大差异将在接下来的三章中讨论,因为那时我们将编写所有游戏对象和组件,并将它们用于GameScreen
类。
第一个区别是GameScreen
类有两个UIPanel
实例和两个InputHandler
实例。
编写GameScreen
类
在Header Files/Screens/Game
筛选器中创建一个新的头文件,名为GameScreen.h
,并添加以下代码:
#pragma once
#include "Screen.h"
#include "GameInputHandler.h"
#include "GameOverInputHandler.h"
class GameScreen : public Screen
{
private:
ScreenManagerRemoteControl* m_ScreenManagerRemoteControl;
shared_ptr<GameInputHandler> m_GIH;
Texture m_BackgroundTexture;
Sprite m_BackgroundSprite;
public:
static bool m_GameOver;
GameScreen(ScreenManagerRemoteControl* smrc, Vector2i res);
void initialise() override;
void virtual update(float fps);
void virtual draw(RenderWindow& window);
};
注意,这还不是最终的代码——我们将在下一章中向此文件添加更多功能。这只是一些代码,足以让我们运行游戏,并在本章末尾看到一些基本功能。
代码与SelectScreen
类相似。我们还重写了initialise
和update
函数。此外,我们添加了一个布尔值m_GameOver
,它将跟踪游戏是否正在播放。
让我们继续到函数实现。
在Source Files/Screens/Game
筛选器中创建一个新的源文件,名为GameScreen.cpp
,并添加以下代码:
#include "GameScreen.h"
#include "GameUIPanel.h"
#include "GameInputHandler.h"
#include "GameOverUIPanel.h"
#include "WorldState.h"
int WorldState::WORLD_HEIGHT;
int WorldState::NUM_INVADERS;
int WorldState::NUM_INVADERS_AT_START;
GameScreen::GameScreen(ScreenManagerRemoteControl* smrc,
Vector2i res)
{
m_GIH = make_shared<GameInputHandler>();
auto guip = make_unique<GameUIPanel>(res);
addPanel(move(guip), smrc, m_GIH);
auto m_GOIH = make_shared<GameOverInputHandler>();
auto gouip = make_unique<GameOverUIPanel>(res);
addPanel(move(gouip), smrc, m_GOIH);
m_ScreenManagerRemoteControl = smrc;
float screenRatio = VideoMode::getDesktopMode().width /
VideoMode::getDesktopMode().height;
WorldState::WORLD_HEIGHT = WorldState::WORLD_WIDTH /
screenRatio;
m_View.setSize(
WorldState::WORLD_WIDTH, WorldState::WORLD_HEIGHT);
m_View.setCenter(Vector2f(WorldState::WORLD_WIDTH /
2, WorldState::WORLD_HEIGHT / 2));
m_BackgroundTexture.loadFromFile("graphics/background.png");
m_BackgroundSprite.setTexture(m_BackgroundTexture);
auto textureSize = m_BackgroundSprite.getTexture()->getSize();
m_BackgroundSprite.setScale(float(m_View.getSize().x) /
textureSize.x,
float(m_View.getSize().y) / textureSize.y);
}
void GameScreen::initialise()
{
m_GIH->initialize();
WorldState::NUM_INVADERS = 0;
m_GameOver = false;
if (WorldState::WAVE_NUMBER == 0)
{
WorldState::NUM_INVADERS_AT_START =
WorldState::NUM_INVADERS;
WorldState::WAVE_NUMBER = 1;
WorldState::LIVES = 3;
WorldState::SCORE = 0;
}
}
void GameScreen::update(float fps)
{
Screen::update(fps);
if (!m_GameOver)
{
if (WorldState::NUM_INVADERS <= 0)
{
WorldState::WAVE_NUMBER++;
m_ScreenManagerRemoteControl->
loadLevelInPlayMode("level1");
}
if (WorldState::LIVES <= 0)
{
m_GameOver = true;
}
}
}
void GameScreen::draw(RenderWindow& window)
{
// Change to this screen's view to draw
window.setView(m_View);
window.draw(m_BackgroundSprite);
// Draw the UIPanel view(s)
Screen::draw(window);
}
在SelectScreen
类中发生的一切在这里也会发生,但有两个UIPanel
实例和两个InputHandler
实例。下一个区别是GameScreen
类实现了update
函数。这是游戏中的每个游戏对象都会在每个帧更新的地方。
下一个不同之处在于,我们在initialise
和update
函数中添加了一些基本的游戏逻辑。
重要提示
对于initialise
和initialize
函数拼写不一致,我表示歉意。在当前的生产阶段更改它们更有可能将错误引入书中,而不是帮助您。
在initialize
函数中,代码调用了我们将要编写的GameInputHandler
类的initialize
函数。将NUM_INVADERS
变量设置为零,同时将m_GameOver
设置为 false。接下来,测试WAVE_NUMBER
变量,如果它等于零,则WorldState
类的静态变量被初始化,为新的游戏做好准备。
在update
函数中,使用m_GameOver
变量来确定游戏是否正在运行,如果是,则进行两个额外的测试。第一个测试是否所有入侵者都被摧毁了。在当前的开发阶段,因为没有入侵者,这会导致波数不断递增。
第二个测试检查玩家是否用完了生命,如果是,则将m_GameOver
设置为 true。
编写 GameInputHandler 类
在Header Files/Screens/Game
筛选器中创建一个新的头文件,命名为GameInputHandler.h
,并添加以下代码:
#pragma once
#include "InputHandler.h"
class GameScreen;
class GameInputHandler : public InputHandler
{
public:
void initialize();
void handleGamepad() override;
void handleKeyPressed(Event& event,
RenderWindow& window) override;
void handleKeyReleased(Event& event,
RenderWindow& window) override;
};
这个类的工作方式与SelectInputHandler
相同,但我们需要覆盖更多的函数。我们将在initialize
、handleGamepad
、handleKeyPressed
和handleKeyReleased
函数中添加代码。
这还不是最终的代码——我们将在下一章中向这个文件添加更多功能。这只是一些代码,以便我们可以在章节末尾运行游戏并看到一些基本功能。
在Source Files/Screens/Game
筛选器中创建一个新的源文件,命名为GameInputHandler.cpp
,并添加以下代码:
#include "GameInputHandler.h"
#include "SoundEngine.h"
#include "GameScreen.h"
void GameInputHandler::initialize() {
}
void GameInputHandler::handleGamepad()
{
}
void GameInputHandler::handleKeyPressed(
Event& event, RenderWindow& window)
{
// Handle key presses
if (event.key.code == Keyboard::Escape)
{
SoundEngine::playClick();
getPointerToScreenManagerRemoteControl()->
SwitchScreens("Select");
}
}
void GameInputHandler::handleKeyReleased(
Event& event, RenderWindow& window)
{
}
目前,我们只想向handleKeyPressed
函数添加代码,但为什么不添加前面代码中显示的其他空函数呢?当玩家按下Escape键时,ScreenMangerRemoteControl
指针调用SwitchScreen
函数返回到选择屏幕。
这还不是最终的代码——我们将在下一章中向这个文件添加更多功能。这只是一些代码,以便我们可以在章节末尾运行游戏并看到一些基本功能。
编写 GameUIPanel 类
在Header Files/Screens/Game
筛选器中创建一个新的头文件,命名为GameUIPanel.h
,并添加以下代码:
#pragma once
#include "UIPanel.h"
class GameUIPanel : public UIPanel
{
public:
GameUIPanel(Vector2i res);
void draw(RenderWindow& window) override;
};
与之前的UIPanel
子类一样,我们将覆盖draw
函数并实现构造函数。现在让我们编写这些函数。
在Source Files/Screens/Game
筛选器中创建一个新的源文件,命名为GameUIPanel.cpp
,并添加以下代码:
#include "GameUIPanel.h"
#include <sstream>
#include "WorldState.h"
int WorldState::SCORE;
int WorldState::LIVES;
GameUIPanel::GameUIPanel(Vector2i res) :
UIPanel(res,
1, // The left
1, // The top
res.x / 3, // 1/3 width screen
res.y / 12,
50, 255, 255, 255) // a, r, g, b
{
m_Text.setFillColor(sf::Color(0, 255, 0, 255));
m_Text.setString("Score: 0 Lives: 3 Wave: 1");
m_Font.loadFromFile("fonts/Roboto-Bold.ttf");
m_Text.setFont(m_Font);
m_Text.setPosition(Vector2f(15,15));
m_Text.setCharacterSize(60);
}
void GameUIPanel::draw(RenderWindow& window)
{
UIPanel::draw(window);
std::stringstream ss;
ss << "Score: " << WorldState::SCORE << " Lives: "
<< WorldState::LIVES << " Wave: "
<< WorldState::WAVE_NUMBER;
m_Text.setString(ss.str());
window.draw(m_Text);
}
构造函数,就像SelectUIPanel
类一样,调用基类构造函数来配置面板的位置、大小和颜色。此外,在构造函数中,准备了一个Text
实例以在屏幕上绘制。
在draw
函数中,使用stringstream
实例来连接一个字符串,该字符串显示玩家的得分、剩余生命和清除的波数。然后RenderWindow
实例将Text
实例传递给其draw
函数。
编写 GameOverInputHandler 类
记住游戏屏幕将有两个面板和两个输入处理类。当玩家失去最后一条生命时,游戏结束面板将显示出来。这是我们接下来要编写的代码。
在Header Files/Screens/Game
筛选器中创建一个新的头文件,命名为GameOverInputHandler.h
,并添加以下代码:
#pragma once
#include "InputHandler.h"
class GameOverInputHandler :
public InputHandler
{
public:
void handleKeyPressed(Event& event,
RenderWindow& window) override;
void handleLeftClick(std::string&
buttonInteractedWith, RenderWindow& window) override;
};
与前两个InputHandler
派生类的头文件相比,前面的代码没有不同。
在Source Files/Screens/Game
筛选器中创建一个新的源文件,命名为GameOverInputHandler.cpp
,并添加以下代码:
#include "GameOverInputHandler.h"
#include "SoundEngine.h"
#include "WorldState.h"
#include <iostream>
void GameOverInputHandler::handleKeyPressed(Event& event,
RenderWindow& window)
{
if (event.key.code == Keyboard::Escape)
{
SoundEngine::playClick();
getPointerToScreenManagerRemoteControl()->
SwitchScreens("Select");
}
}
void GameOverInputHandler::handleLeftClick(
std::string& buttonInteractedWith, RenderWindow& window)
{
if (buttonInteractedWith == "Play") {
SoundEngine::playClick();
WorldState::WAVE_NUMBER = 0;
getPointerToScreenManagerRemoteControl()->
loadLevelInPlayMode("level1");
}
else if (buttonInteractedWith == "Home") {
SoundEngine::playClick();
getPointerToScreenManagerRemoteControl()->
SwitchScreens("Select");
}
}
前面的代码处理了两种类型的事件。首先,如果按下Esc键盘键,游戏将切换到选择屏幕。
在handleLeftClick
函数中,处理了两个不同的按钮。如果点击了loadLevelInPlayMode
,则,如果点击了主页按钮,则将显示选择屏幕。
编写 GameOverUIPanel 类
在Header Files/Screens/Game
筛选器中创建一个新的头文件,命名为GameOverUIPanel.h
,并添加以下代码:
#pragma once
#include "UIPanel.h"
class GameOverUIPanel :
public UIPanel
{
private:
void initialiseButtons();
public:
GameOverUIPanel(Vector2i res);
void virtual draw(RenderWindow& window);
};
在前面的头文件中没有新内容,所以让我们看看函数实现。
在Source Files/Screens/Game
筛选器中创建一个新的源文件,命名为GameOverUIPanel.cpp
,并添加以下代码:
#include "GameOverUIPanel.h"
#include "GameScreen.h"
bool GameScreen::m_GameOver;
GameOverUIPanel::GameOverUIPanel(Vector2i res) :
UIPanel(res,
(res.x / 10) * 3,
res.y / 2, // 50% of the resolution from the top
(res.x / 10) * 3, // as wide as 1/3 of the resolution
res.y / 6, // and as tall as 1/6 of the resolution
50, 255, 255, 255) // a, r, g, b
{
m_ButtonWidth = res.x / 20;
m_ButtonHeight = res.y / 20;
m_ButtonPadding = res.x / 100;
m_Text.setFillColor(sf::Color(0, 255, 0, 255));// Green
m_Text.setString("GAME OVER!");
m_Font.loadFromFile("fonts/Roboto-Bold.ttf");
m_Text.setFont(m_Font);
m_Text.setPosition(Vector2f(m_ButtonPadding,
(m_ButtonPadding * 2)+ m_ButtonHeight));
m_Text.setCharacterSize(60);
initialiseButtons();
}
void GameOverUIPanel::initialiseButtons()
{
addButton(m_ButtonPadding,
m_ButtonPadding,
m_ButtonWidth,
m_ButtonHeight,
0, 255, 0,
"Play");
addButton(m_ButtonWidth + (m_ButtonPadding * 2),
m_ButtonPadding,
m_ButtonWidth,
m_ButtonHeight,
255, 0, 0,
"Home");
}
void GameOverUIPanel::draw(RenderWindow& window)
{
if (GameScreen::m_GameOver)
{
show();
UIPanel::draw(window);
window.draw(m_Text);
}
else
{
hide();
}
}
前面的代码在屏幕中间配置了一个面板,上面有游戏结束的文本和两个按钮,允许玩家重新开始游戏或退出,并返回到起始屏幕(主页/选择)。
运行游戏
如果你运行游戏,你将看到选择屏幕,如下面的截图所示:
按播放键切换到游戏屏幕:
按Esc键退出,并返回到选择屏幕。
退出游戏,并在GameScreen
类中找到以下代码行:
if (WorldState::LIVES <= 0)
更改为以下内容:
if (true)
现在,再次运行游戏并选择播放按钮。游戏结束面板将显示出来,可以与之交互:
现在,将GameScreen
类中的if (true)
改回if (WorldState::LIVES <= 0)
。
让我们休息一下;这是一章很长的内容。
摘要
你在本章中取得了很大的成就。你已经为 Space Invaders ++游戏建立了坚实的基础,并且你还编写了一个可重用的系统,该系统可以用于几乎任何被划分为不同“屏幕”的游戏。
我们现在已经建立了一个输入处理系统,它可以检测键盘按键和鼠标点击,并将处理这些输入的责任路由到特定屏幕的一部分特定面板。此外,屏幕概念的抽象化使我们能够设置尽可能多的不同游戏循环。GameScreen
类将是处理这个游戏逻辑的主要类,但在接下来的几章中,你将看到如何轻松地编写另一个屏幕来玩一个完全不同的游戏。当然,你最有可能会从自己的想法开始着手。
在下一章中,我们将编写游戏对象和组件,它们是我们实体-组件模式实现的基础。
第二十一章:第二十章:游戏对象和组件
在本章中,我们将编写与上一章开头讨论的实体-组件模式相关的所有编码。这意味着我们将编写基础Component
类,其他所有组件都将从这个类派生。我们还将充分利用我们对智能指针的新知识,以便我们不必担心跟踪为这些组件分配的内存。我们还将在本章中编写GameObject
类。
本章我们将涵盖以下主题:
-
准备编写组件
-
编写组件基类
-
编写碰撞器组件
-
编写图形组件
-
编写更新组件
-
编写游戏对象类
在我们开始编码之前,让我们更详细地讨论一下组件。请注意,在本章中,我将尝试加强实体-组件系统如何结合在一起,以及所有组件如何组成一个游戏对象。我不会解释每一行或甚至每一个逻辑块或已经多次见过的 SFML 相关代码。这些细节需要你自己去研究。
准备编写组件
在你完成本章的过程中,会有很多错误,其中一些可能看起来没有逻辑。例如,你可能会得到错误信息,说某个类不存在,而实际上它正是你已经编写的类之一。原因在于,当一个类中存在错误时,其他类无法可靠地使用它,否则也会出现错误。正因所有类之间相互关联的特性,我们直到下一章的结尾才能消除所有错误,再次获得可执行的代码。本可以分小块向各个类和项目添加代码,这样项目出现错误的频率会更高。然而,逐步进行意味着需要不断在各个类之间切换。当你构建自己的项目时,这有时是一种好的做法,但我认为对于这个项目来说,最有教育意义的事情是帮助你尽可能快地构建它。
编写组件基类
在Header Files/GameObjects
过滤器中创建一个新的头文件,命名为Component.h
,并添加以下代码:
#pragma once
#include "GameObjectSharer.h"
#include <string>
using namespace std;
class GameObject;
class Component {
public:
virtual string getType() = 0;
virtual string getSpecificType() = 0;
virtual void disableComponent() = 0;
virtual void enableComponent() = 0;
virtual bool enabled() = 0;
virtual void start(GameObjectSharer* gos, GameObject* self) = 0;
};
这是每个游戏对象中每个组件的基类。纯虚函数意味着组件不能被实例化,必须首先继承。函数允许访问组件的类型和特定类型。组件类型包括碰撞器、图形、变换和更新,但根据游戏需求还可以添加更多类型。具体类型包括标准图形、入侵者更新、玩家更新等。
有两个函数允许组件被启用和禁用。这很有用,因为组件可以在使用之前测试它是否当前已启用。例如,你可以调用 enabled
函数来测试在调用 update
函数之前组件的更新组件是否已启用,或者图形组件在调用 draw
函数之前是否已启用。
start
函数可能是最有趣的功能,因为它将其参数之一设为一个新的类类型。GameObjectSharer
类将在所有组件实例化后提供对所有游戏对象的访问。这将给每个游戏对象中的每个组件提供查询详细信息甚至获取指向另一个游戏对象中特定数据指针的机会。例如,所有侵略者的更新组件都需要知道玩家变换组件的位置,以便知道何时开火。在 start
函数中,可以访问任何对象的任何部分。关键是每个特定的组件将决定它们需要什么,并且在关键的游戏循环中不需要查询另一个游戏对象的详细信息。
包含该组件的 GameObject
也会传递给 start
函数,这样任何组件都可以了解更多关于自己的信息。例如,图形组件需要了解变换组件,以便知道在哪里绘制自己。作为第二个例子,侵略者和玩家飞船的更新组件需要指向它们自己的碰撞器组件的指针,这样它们就可以在移动时更新其位置。
随着我们继续前进,我们将看到更多 start
函数的使用案例。
在 Source Files/GameObjects
过滤器中创建一个新的源文件,命名为 Component.cpp
,并添加以下代码:
/*********************************
******THIS IS AN INTERFACE********
*********************************/
由于 Component
类永远不能实例化,我将前面的注释放在 Component.cpp
中作为提醒。
编写碰撞器组件
《太空侵略者++》游戏将只包含一种简单的碰撞器类型。它将是一个围绕对象的矩形框,就像我们在《僵尸末日》和《乒乓》游戏中使用的那样。然而,很容易想象你可能需要其他类型的碰撞器;可能是一个圆形碰撞器,或者是一个非包围的碰撞器,就像我们在《托马斯迟到了》游戏中用于托马斯和鲍勃的头、脚和侧面的那些。
因此,将有一个基类 ColliderComponent
(继承自 Component
),它将处理所有碰撞器的基本功能,以及 RectColliderComponent
,它将添加包围矩形形状碰撞器的特定功能。然后可以根据正在开发的游戏需求添加新的碰撞器类型。
接下来是特定碰撞器的基类,ColliderComponent
。
编写 ColliderComponent
类
在Header Files/GameObjects
过滤器中创建一个新的头文件,命名为ColliderComponent.h
,并添加以下代码:
#pragma once
#include "Component.h"
#include <iostream>
class ColliderComponent : public Component
{
private:
string m_Type = "collider";
bool m_Enabled = false;
public:
/****************************************************
*****************************************************
From Component interface
*****************************************************
*****************************************************/
string Component::getType() {
return m_Type;
}
void Component::disableComponent() {
m_Enabled = false;
}
void Component::enableComponent() {
m_Enabled = true;
}
bool Component::enabled() {
return m_Enabled;
}
void Component::start(GameObjectSharer* gos, GameObject* self)
{
}
};
ColliderComponent
类从Component
类继承。在前面的代码中,你可以看到m_Type
成员变量被初始化为"collider"
,而m_Enabled
被初始化为false
。
在public
部分,代码覆盖了Component
类的纯虚函数。研究它们,以便熟悉它们,因为它们在所有组件类中都以非常相似的方式工作。getType
函数返回m_Type
。disableComponent
函数将m_Enabled
设置为false
。enableComponent
函数将m_Enabled
设置为true
。enabled
函数返回m_Enabled
的值。start
函数没有代码,但将被许多更具体的基于组件的类覆盖。
在Source Files/GameObjects
过滤器中创建一个新的源文件,命名为ColliderComponent.cpp
,并添加以下代码:
/*
All Functionality in ColliderComponent.h
*/
我在ColliderComponent.cpp
中添加了前面的注释,以提醒自己所有功能都在头文件中。
编写 RectColliderComponent 类
在Header Files/GameObjects
过滤器中创建一个新的头文件,命名为RectColliderComponent.h
,并添加以下代码:
#pragma once
#include "ColliderComponent.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class RectColliderComponent : public ColliderComponent
{
private:
string m_SpecificType = "rect";
FloatRect m_Collider;
string m_Tag = "";
public:
RectColliderComponent(string name);
string getColliderTag();
void setOrMoveCollider(
float x, float y, float width, float height);
FloatRect& getColliderRectF();
/****************************************************
*****************************************************
From Component interface base class
*****************************************************
*****************************************************/
string getSpecificType() {
return m_SpecificType;
}
void Component::start(
GameObjectSharer* gos, GameObject* self) {}
};
RectColliderComponent
类从ColliderComponent
类继承。它有一个m_SpecificType
变量,初始化为"rect"
。现在可以查询向量中的任何RectColliderComponent
实例,该向量包含通用的Component
实例,并确定它具有类型"collider"
和特定类型"rect"
。所有基于组件的类都将具有此功能,因为这是Component
类的纯虚函数所提供的。
还有一个名为m_Collider
的FloatRect
实例,它将存储此碰撞器的坐标。
在public
部分,我们可以查看构造函数。注意,它接收一个string
。传入的值将是标识此RectColliderComponent
附加到的游戏对象类型的文本,例如入侵者、子弹或玩家的飞船。这样就可以确定哪些类型的对象相互碰撞了。
在重写函数之前还有三个函数;记下它们的名称和参数,然后我们将在编写它们的定义时稍后讨论它们。
注意,getSpecificType
函数定义返回m_SpecificType
。
在Source Files/GameObjects
过滤器中创建一个新的源文件,命名为RectColliderComponent.cpp
,并添加以下代码:
#include "RectColliderComponent.h"
RectColliderComponent::RectColliderComponent(string name) {
m_Tag = "" + name;
}
string RectColliderComponent::getColliderTag() {
return m_Tag;
}
void RectColliderComponent::setOrMoveCollider(
float x, float y, float width, float height) {
m_Collider.left = x;
m_Collider.top = y;
m_Collider.width = width;
m_Collider.height = height;
}
FloatRect& RectColliderComponent::getColliderRectF() {
return m_Collider;
}
在构造函数中,传入的string
值被分配给m_Tag
变量,而getColliderTag
函数则通过类的实例使该值可用。
setOrMoveCollider
函数将m_Collider
定位到作为参数传入的坐标。
getColliderRectF
函数返回对m_Collider
的引用。这非常适合使用FloatRect
类的intersects
函数与另一个碰撞器进行碰撞测试。
我们现在已经完成了碰撞器,可以继续进行图形处理。
编写图形组件
Space Invaders ++游戏将只有一种特定的图形组件。它被称为StandardGraphicsComponent
。与碰撞器组件一样,我们将实现一个基本的GraphicsComponent
类,以便于将来添加其他图形相关组件。例如,在经典的太空侵略者街机版本中,侵略者会通过两个动画帧上下摆动手臂。一旦你了解了StandardGraphicsComponent
的工作原理,你将能够轻松地编写另一个类(可能是AnimatedGraphicsComponent
),它每隔半秒左右使用不同的Sprite
实例绘制自己。你也可以有一个具有着色器(可能是ShaderGraphicsComponent
)的图形组件,以实现快速和酷炫的效果。除了这些之外,还有更多可能性。
编写GraphicsComponent
类
在Header Files/GameObjects
筛选器中创建一个新的头文件,命名为GraphicsComponent.h
,并添加以下代码:
#pragma once
#include "Component.h"
#include "TransformComponent.h"
#include <string>
#include <SFML/Graphics.hpp>
#include "GameObjectSharer.h"
#include <iostream>
using namespace sf;
using namespace std;
class GraphicsComponent : public Component {
private:
string m_Type = "graphics";
bool m_Enabled = false;
public:
virtual void draw(
RenderWindow& window,
shared_ptr<TransformComponent> t) = 0;
virtual void initializeGraphics(
string bitmapName,
Vector2f objectSize) = 0;
/****************************************************
*****************************************************
From Component interface
*****************************************************
*****************************************************/
string Component::getType() {
return m_Type;
}
void Component::disableComponent() {
m_Enabled = false;
}
void Component::enableComponent() {
m_Enabled = true;
}
bool Component::enabled() {
return m_Enabled;
}
void Component::start(
GameObjectSharer* gos, GameObject* self) {}
};
之前的大部分代码实现了Component
类的纯虚函数。对于GraphicsComponent
类来说,新的是draw
函数,它有两个参数。第一个参数是RenderWindow
实例的引用,以便组件可以绘制自己,而第二个参数是GameObject
的TransformComponent
实例的共享智能指针,以便在游戏的每一帧可以访问诸如位置和缩放等关键数据。
initializeGraphics
函数也是GraphicsComponent
类中新增的,它也有两个参数。第一个是一个string
值,表示要使用的图形文件的文件名,而第二个是一个Vector2f
实例,它将代表游戏世界中对象的大小。
这两个函数都是纯虚函数,这使得GraphicsComponent
类成为抽象类。任何从GraphicsComponent
继承的类都需要实现这些函数。在下一节中,我们将看到StandardGraphicsComponent
是如何做到这一点的。
在Source Files/GameObjects
筛选器中创建一个新的源文件,命名为GraphicsComponent.cpp
,并添加以下代码:
/*
All Functionality in GraphicsComponent.h
*/
之前的注释是一个提醒,说明代码都在相关的头文件中。
编写StandardGraphicsComponent
类
在Header Files/GameObjects
筛选器中创建一个新的头文件,命名为StandardGraphicsComponent.h
,并添加以下代码:
#pragma once
#include "Component.h"
#include "GraphicsComponent.h"
#include <string>
class Component;
class StandardGraphicsComponent : public GraphicsComponent {
private:
sf::Sprite m_Sprite;
string m_SpecificType = "standard";
public:
/****************************************************
*****************************************************
From Component interface base class
*****************************************************
*****************************************************/
string Component::getSpecificType() {
return m_SpecificType;
}
void Component::start(
GameObjectSharer* gos, GameObject* self) {
}
/****************************************************
*****************************************************
From GraphicsComponent
*****************************************************
*****************************************************/
void draw(
RenderWindow& window,
shared_ptr<TransformComponent> t) override;
void initializeGraphics(
string bitmapName,
Vector2f objectSize) override;
};
StandardGraphicsComponent
类有一个Sprite
成员。它不需要一个Texture
实例,因为每个帧都会从BitmapStore
类中获取。这个类还重写了Component
和GraphicsComponent
类中所需的所有函数。
让我们编码两个纯虚函数draw
和initializeGraphics
的实现。
在Source Files/GameObjects
筛选器中创建一个新的源文件,命名为StandardGraphicsComponent.cpp
,并添加以下代码:
#include "StandardGraphicsComponent.h"
#include "BitmapStore.h"
#include <iostream>
void StandardGraphicsComponent::initializeGraphics(
string bitmapName,
Vector2f objectSize)
{
BitmapStore::addBitmap("graphics/" + bitmapName + ".png");
m_Sprite.setTexture(BitmapStore::getBitmap(
"graphics/" + bitmapName + ".png"));
auto textureSize = m_Sprite.getTexture()->getSize();
m_Sprite.setScale(float(objectSize.x) / textureSize.x,
float(objectSize.y) / textureSize.y);
m_Sprite.setColor(sf::Color(0, 255, 0));
}
void StandardGraphicsComponent::draw(
RenderWindow& window,
shared_ptr<TransformComponent> t)
{
m_Sprite.setPosition(t->getLocation());
window.draw(m_Sprite);
}
在initializeGraphics
函数中,调用了BitmapStore
类的addBitmap
函数,并将图像的文件路径以及游戏世界中对象的尺寸传递进去。
接下来,检索刚刚添加到BitmapStore
类的Texture
实例,并将其设置为Sprite
的图像。随后,将getTexture
和getSize
两个函数串联起来以获取纹理的尺寸。
下一条代码使用setScale
函数使Sprite
与纹理大小相同,而纹理的大小被设置为游戏世界中此对象的尺寸。
然后,setColor
函数为Sprite
应用绿色色调。这给它增添了一丝复古的感觉。
在draw
函数中,使用setPosition
和TransformComponent
的getLocation
函数将Sprite
移动到指定位置。接下来,我们将编码TransformComponent
类。
最后一行代码将Sprite
绘制到RenderWindow
。
编码 TransformComponent 类
在Header Files/GameObjects
筛选器中创建一个新的头文件,命名为TransformComponent.h
,并添加以下代码:
#pragma once
#include "Component.h"
#include<SFML/Graphics.hpp>
using namespace sf;
class Component;
class TransformComponent : public Component {
private:
const string m_Type = "transform";
Vector2f m_Location;
float m_Height;
float m_Width;
public:
TransformComponent(
float width, float height, Vector2f location);
Vector2f& getLocation();
Vector2f getSize();
/****************************************************
*****************************************************
From Component interface
*****************************************************
*****************************************************/
string Component::getType()
{
return m_Type;
}
string Component::getSpecificType()
{
// Only one type of Transform so just return m_Type
return m_Type;
}
void Component::disableComponent(){}
void Component::enableComponent(){}
bool Component::enabled()
{
return false;
}
void Component::start(GameObjectSharer* gos, GameObject* self) {}
};
此类有一个Vector2f
用于存储游戏世界中对象的定位,一个float
用于存储高度,另一个float
用于存储宽度。
在public
部分,有一个构造函数,我们将使用它来设置此类实例,以及两个函数getLocation
和getSize
,我们将使用它们来共享对象的定位和尺寸。我们在编码StandardGraphicsComponent
类时已经使用了这些函数。
TransformComponent.h
文件中的剩余代码是Component
类的实现。
在Source Files/GameObjects
筛选器中创建一个新的源文件,命名为TransformComponent.cpp
,并添加以下代码:
#include "TransformComponent.h"
TransformComponent::TransformComponent(
float width, float height, Vector2f location)
{
m_Height = height;
m_Width = width;
m_Location = location;
}
Vector2f& TransformComponent::getLocation()
{
return m_Location;
}
Vector2f TransformComponent::getSize()
{
return Vector2f(m_Width, m_Height);
}
实现此类中的三个函数很简单。构造函数接收一个尺寸和一个位置,并初始化相应的成员变量。当请求时,getLocation
和getSize
函数返回这些数据。请注意,值是通过引用返回的,因此它们可以被调用代码修改。
接下来,我们将编码所有与更新相关的组件。
编码更新组件
如你所料,我们将编写一个继承自Component
类的UpdateComponent
类。它将包含每个UpdateComponent
所需的所有功能,然后我们将编写从UpdateComponent
派生的类。这些类将包含针对游戏中单个对象的功能。对于这个游戏,我们将有BulletUpdateComponent
、InvaderUpdateComponent
和PlayerUpdateComponent
。当你在自己的项目中工作时,如果你想创建一个以特定独特方式行为的游戏对象,只需为它编写一个新的基于更新的组件,然后你就可以开始了。基于更新的组件定义行为。
编写 UpdateComponent 类
在Header Files/GameObjects
筛选器中创建一个新的头文件,命名为UpdateComponent.h
,并添加以下代码:
#pragma once
#include "Component.h"
class UpdateComponent : public Component
{
private:
string m_Type = "update";
bool m_Enabled = false;
public:
virtual void update(float fps) = 0;
/****************************************************
*****************************************************
From Component interface
*****************************************************
*****************************************************/
string Component::getType() {
return m_Type;
}
void Component::disableComponent() {
m_Enabled = false;
}
void Component::enableComponent() {
m_Enabled = true;
}
bool Component::enabled() {
return m_Enabled;
}
void Component::start(
GameObjectSharer* gos, GameObject* self) {
}
};
UpdateComponent
只提供一项功能:update
函数。这个函数是纯虚函数,因此任何希望成为UpdateComponent
可用实例的类都必须实现它。
在Source Files/GameObjects
筛选器中创建一个新的源文件,命名为UpdateComponent.cpp
,并添加以下代码:
/*
All Functionality in UpdateComponent.h
*/
这是一个有用的注释,提醒我们这个类的所有代码都在相关的头文件中。
编写 BulletUpdateComponent 类
在Header Files/GameObjects
筛选器中创建一个新的头文件,命名为BulletUpdateComponent.h
,并添加以下代码:
#pragma once
#include "UpdateComponent.h"
#include "TransformComponent.h"
#include "GameObjectSharer.h"
#include "RectColliderComponent.h"
#include "GameObject.h"
class BulletUpdateComponent : public UpdateComponent
{
private:
string m_SpecificType = "bullet";
shared_ptr<TransformComponent> m_TC;
shared_ptr<RectColliderComponent> m_RCC;
float m_Speed = 75.0f;
int m_AlienBulletSpeedModifier;
int m_ModifierRandomComponent = 5;
int m_MinimumAdditionalModifier = 5;
bool m_MovingUp = true;
public:
bool m_BelongsToPlayer = false;
bool m_IsSpawned = false;
void spawnForPlayer(Vector2f spawnPosition);
void spawnForInvader(Vector2f spawnPosition);
void deSpawn();
bool isMovingUp();
/****************************************************
*****************************************************
From Component interface base class
*****************************************************
*****************************************************/
string Component::getSpecificType() {
return m_SpecificType;
}
void Component::start(
GameObjectSharer* gos, GameObject* self) {
// Where is this specific invader
m_TC = static_pointer_cast<TransformComponent>(
self->getComponentByTypeAndSpecificType(
"transform", "transform"));
m_RCC = static_pointer_cast<RectColliderComponent>(
self->getComponentByTypeAndSpecificType(
"collider", "rect"));
}
/****************************************************
*****************************************************
From UpdateComponent
*****************************************************
*****************************************************/
void update(float fps) override;
};
如果你想了解子弹的行为/逻辑,你需要花一些时间学习成员变量名称和类型,因为我不会精确解释子弹是如何行为的;我们已经多次覆盖了这些主题。然而,我会指出,有一些变量用于处理基本操作,如移动,还有一些变量用于帮助在特定范围内随机化每颗子弹的速度,以及布尔值用于标识子弹属于玩家还是入侵者。
你现在还不知道但必须在这里学习的关键点是,每个BulletUpdateComponent
实例将持有对拥有游戏对象的TransformComponent
实例的共享指针和对拥有游戏对象的RectColliderComponent
实例的共享指针。
现在,仔细看看重写的start
函数。在start
函数中,上述共享指针被初始化。代码通过使用拥有游戏对象的getComponentByTypeAndSpecificType
函数(self
是一个指向拥有游戏对象的指针)来实现这一点。我们将在稍后的部分中编码GameObject
类,包括这个函数。
在Source Files/GameObjects
筛选器中创建一个新的源文件,命名为BulletUpdate.cpp
,并添加以下代码:
#include "BulletUpdateComponent.h"
#include "WorldState.h"
void BulletUpdateComponent::spawnForPlayer(
Vector2f spawnPosition)
{
m_MovingUp = true;
m_BelongsToPlayer = true;
m_IsSpawned = true;
m_TC->getLocation().x = spawnPosition.x;
// Tweak the y location based on the height of the bullet
// The x location is already tweaked to the center of the player
m_TC->getLocation().y = spawnPosition.y - m_TC->getSize().y;
// Update the collider
m_RCC->setOrMoveCollider(m_TC->getLocation().x,
m_TC->getLocation().y,
m_TC->getSize().x, m_TC->getSize().y);
}
void BulletUpdateComponent::spawnForInvader(
Vector2f spawnPosition)
{
m_MovingUp = false;
m_BelongsToPlayer = false;
m_IsSpawned = true;
srand((int)time(0));
m_AlienBulletSpeedModifier = (
((rand() % m_ModifierRandomComponent)))
+ m_MinimumAdditionalModifier;
m_TC->getLocation().x = spawnPosition.x;
// Tweak the y location based on the height of the bullet
// The x location already tweaked to the center of the invader
m_TC->getLocation().y = spawnPosition.y;
// Update the collider
m_RCC->setOrMoveCollider(
m_TC->getLocation().x, m_TC->
getLocation().y, m_TC->getSize().x, m_TC->getSize().y);
}
void BulletUpdateComponent::deSpawn()
{
m_IsSpawned = false;
}
bool BulletUpdateComponent::isMovingUp()
{
return m_MovingUp;
}
void BulletUpdateComponent::update(float fps)
{
if (m_IsSpawned)
{
if (m_MovingUp)
{
m_TC->getLocation().y -= m_Speed * fps;
}
else
{
m_TC->getLocation().y += m_Speed /
m_AlienBulletSpeedModifier * fps;
}
if (m_TC->getLocation().y > WorldState::WORLD_HEIGHT
|| m_TC->getLocation().y < -2)
{
deSpawn();
}
// Update the collider
m_RCC->setOrMoveCollider(m_TC->getLocation().x,
m_TC->getLocation().y,
m_TC->getSize().x, m_TC->getSize().y);
}
}
前两个函数是 BulletUpdateComponent
类独有的;它们是 spawnForPlayer
和 spawnForInvader
。这两个函数都为成员变量、变换组件和碰撞器组件准备行动。每个都略有不同。例如,对于玩家拥有的子弹,它被准备从玩家的船顶向上移动,而对于入侵者的子弹,它被准备从入侵者的底部向下移动屏幕。要注意的关键是,所有这些都可以通过变换组件和碰撞器组件的共享指针来实现。此外,请注意,m_IsSpawned
布尔值被设置为 true,这使得这个更新组件的 update
函数准备好在每一帧调用游戏。
在 update
函数中,子弹以适当的速度在屏幕上下移动。它被测试以查看是否已经消失在屏幕顶部或底部,并且碰撞器被更新以包裹当前位置,以便我们可以测试碰撞。
这是我们在这本书中看到的相同逻辑;新的地方是我们用来与其他组成游戏对象的组件通信的共享指针。
子弹只需要被生成并测试碰撞;我们将在下一章中看到如何做。现在,我们将编写入侵者的行为代码。
编写 InvaderUpdateComponent 类
在 Header Files/GameObjects
过滤器中创建一个新的头文件,命名为 InvaderUpdateComponent.h
,并添加以下代码:
#pragma once
#include "UpdateComponent.h"
#include "TransformComponent.h"
#include "GameObjectSharer.h"
#include "RectColliderComponent.h"
#include "GameObject.h"
class BulletSpawner;
class InvaderUpdateComponent : public UpdateComponent
{
private:
string m_SpecificType = "invader";
shared_ptr<TransformComponent> m_TC;
shared_ptr < RectColliderComponent> m_RCC;
shared_ptr < TransformComponent> m_PlayerTC;
shared_ptr < RectColliderComponent> m_PlayerRCC;
BulletSpawner* m_BulletSpawner;
float m_Speed = 10.0f;
bool m_MovingRight = true;
float m_TimeSinceLastShot;
float m_TimeBetweenShots = 5.0f;
float m_AccuracyModifier;
float m_SpeedModifier = 0.05;
int m_RandSeed;
public:
void dropDownAndReverse();
bool isMovingRight();
void initializeBulletSpawner(BulletSpawner*
bulletSpawner, int randSeed);
/****************************************************
*****************************************************
From Component interface base class
*****************************************************
*****************************************************/
string Component::getSpecificType() {
return m_SpecificType;
}
void Component::start(GameObjectSharer* gos,
GameObject* self) {
// Where is the player?
m_PlayerTC = static_pointer_cast<TransformComponent>(
gos->findFirstObjectWithTag("Player")
.getComponentByTypeAndSpecificType(
"transform", "transform"));
m_PlayerRCC = static_pointer_cast<RectColliderComponent>(
gos->findFirstObjectWithTag("Player")
.getComponentByTypeAndSpecificType(
"collider", "rect"));
// Where is this specific invader
m_TC = static_pointer_cast<TransformComponent>(
self->getComponentByTypeAndSpecificType(
"transform", "transform"));
m_RCC = static_pointer_cast<RectColliderComponent>(
self->getComponentByTypeAndSpecificType(
"collider", "rect"));
}
/****************************************************
*****************************************************
From UpdateComponent
*****************************************************
*****************************************************/
void update(float fps) override;
};
在类声明中,我们可以看到编写入侵者行为所需的全部功能。有一个指向变换组件的指针,这样入侵者就可以移动,以及一个指向碰撞器组件的指针,这样它就可以更新其位置并被碰撞:
shared_ptr<TransformComponent> m_TC;
shared_ptr < RectColliderComponent> m_RCC;
有指向玩家变换和碰撞器的指针,这样入侵者可以查询玩家的位置并决定何时射击子弹:
shared_ptr < TransformComponent> m_PlayerTC;
shared_ptr < RectColliderComponent> m_PlayerRCC;
接下来,有一个 BulletSpawner
实例,我们将在下一章中编写。BulletSpawner
类将允许入侵者或玩家生成子弹。
接下来是一系列我们将用来控制速度、方向、射击速率、入侵者瞄准的精确度以及发射子弹速度的变量。熟悉它们,因为它们将在函数定义中的相当深入的逻辑中使用:
float m_Speed = 10.0f;
bool m_MovingRight = true;
float m_TimeSinceLastShot;
float m_TimeBetweenShots = 5.0f;
float m_AccuracyModifier;
float m_SpeedModifier = 0.05;
int m_RandSeed;
接下来,我们可以看到三个新的公共函数,系统中的不同部分可以调用这些函数使入侵者稍微向下移动并改变方向,测试移动方向,并分别传递上述 BulletSpawner
类的指针:
void dropDownAndReverse();
bool isMovingRight();
void initializeBulletSpawner(BulletSpawner*
bulletSpawner, int randSeed);
一定要研究 start
函数,其中初始化了指向入侵者和玩家的智能指针。现在,我们将编写函数定义。
在 Source Files/GameObjects
过滤器中创建一个新的源文件,名为 InvaderUpdate.cpp
,并添加以下代码:
#include "InvaderUpdateComponent.h"
#include "BulletSpawner.h"
#include "WorldState.h"
#include "SoundEngine.h"
void InvaderUpdateComponent::update(float fps)
{
if (m_MovingRight)
{
m_TC->getLocation().x += m_Speed * fps;
}
else
{
m_TC->getLocation().x -= m_Speed * fps;
}
// Update the collider
m_RCC->setOrMoveCollider(m_TC->getLocation().x,
m_TC->getLocation().y, m_TC->getSize().x, m_TC-
>getSize().y);
m_TimeSinceLastShot += fps;
// Is the middle of the invader above the
// player +- 1 world units
if ((m_TC->getLocation().x + (m_TC->getSize().x / 2)) >
(m_PlayerTC->getLocation().x - m_AccuracyModifier) &&
(m_TC->getLocation().x + (m_TC->getSize().x / 2)) <
(m_PlayerTC->getLocation().x +
(m_PlayerTC->getSize().x + m_AccuracyModifier)))
{
// Has the invader waited long enough since the last shot
if (m_TimeSinceLastShot > m_TimeBetweenShots)
{
SoundEngine::playShoot();
Vector2f spawnLocation;
spawnLocation.x = m_TC->getLocation().x +
m_TC->getSize().x / 2;
spawnLocation.y = m_TC->getLocation().y +
m_TC->getSize().y;
m_BulletSpawner->spawnBullet(spawnLocation, false);
srand(m_RandSeed);
int mTimeBetweenShots = (((rand() % 10))+1) /
WorldState::WAVE_NUMBER;
m_TimeSinceLastShot = 0;
}
}
}
void InvaderUpdateComponent::dropDownAndReverse()
{
m_MovingRight = !m_MovingRight;
m_TC->getLocation().y += m_TC->getSize().y;
m_Speed += (WorldState::WAVE_NUMBER) +
(WorldState::NUM_INVADERS_AT_START
- WorldState::NUM_INVADERS)
* m_SpeedModifier;
}
bool InvaderUpdateComponent::isMovingRight()
{
return m_MovingRight;
}
void InvaderUpdateComponent::initializeBulletSpawner(
BulletSpawner* bulletSpawner, int randSeed)
{
m_BulletSpawner = bulletSpawner;
m_RandSeed = randSeed;
srand(m_RandSeed);
m_TimeBetweenShots = (rand() % 15 + m_RandSeed);
m_AccuracyModifier = (rand() % 2);
m_AccuracyModifier += 0 + static_cast <float> (
rand()) / (static_cast <float> (RAND_MAX / (10)));
}
这段代码很多。实际上,其中没有我们之前没有见过的 C++ 代码。它只是控制入侵者行为的逻辑。让我们概述一下它所做的一切,并方便地重新打印部分代码。
解释 update
函数
第一个 if
和 else
块根据需要将入侵者向右或向左移动每一帧:
void InvaderUpdateComponent::update(float fps)
{
if (m_MovingRight)
{
m_TC->getLocation().x += m_Speed * fps;
}
else
{
m_TC->getLocation().x -= m_Speed * fps;
}
接下来,将碰撞器更新到新位置:
// Update the collider
m_RCC->setOrMoveCollider(m_TC->getLocation().x,
m_TC->getLocation().y, m_TC->getSize().x, m_TC
->getSize().y);
这段代码追踪自上次入侵者开火以来已经过去的时间,然后测试玩家是否位于入侵者左侧或右侧一个世界单位的位置(+ 或 - 用于随机精度修正,使得每个入侵者都略有不同):
m_TimeSinceLastShot += fps;
// Is the middle of the invader above the
// player +- 1 world units
if ((m_TC->getLocation().x + (m_TC->getSize().x / 2)) >
(m_PlayerTC->getLocation().x - m_AccuracyModifier) &&
(m_TC->getLocation().x + (m_TC->getSize().x / 2)) <
(m_PlayerTC->getLocation().x +
(m_PlayerTC->getSize().x + m_AccuracyModifier)))
{
在前面的 if
测试中,另一个测试确保入侵者自上次射击以来已经等待了足够长的时间。如果是这样,那么就会开火。播放声音,计算子弹的生成位置,调用 BulletSpawner
实例的 spawnBullet
函数,并计算下一次射击前的新随机等待时间:
// Has the invader waited long enough since the last shot
if (m_TimeSinceLastShot > m_TimeBetweenShots)
{
SoundEngine::playShoot();
Vector2f spawnLocation;
spawnLocation.x = m_TC->getLocation().x +
m_TC->getSize().x / 2;
spawnLocation.y = m_TC->getLocation().y +
m_TC->getSize().y;
m_BulletSpawner->spawnBullet(spawnLocation, false);
srand(m_RandSeed);
int mTimeBetweenShots = (((rand() % 10))+1) /
WorldState::WAVE_NUMBER;
m_TimeSinceLastShot = 0;
}
}
}
BulletSpawner
类的详细信息将在下一章中揭晓,但作为一个对未来的预览,它将是一个具有一个名为 spawnBullet
的函数的抽象类,并将由 GameScreen
类继承。
解释 dropDownAndReverse
函数
在 dropDownAndReverse
函数中,方向被反转,垂直位置增加一个入侵者的高度。此外,入侵者的速度相对于玩家清除的波数和剩余要摧毁的入侵者数量而增加。清除的波数越多,剩余的入侵者越少,入侵者的移动速度就越快:
void InvaderUpdateComponent::dropDownAndReverse()
{
m_MovingRight = !m_MovingRight;
m_TC->getLocation().y += m_TC->getSize().y;
m_Speed += (WorldState::WAVE_NUMBER) +
(WorldState::NUM_INVADERS_AT_START
- WorldState::NUM_INVADERS)
* m_SpeedModifier;
}
下一个函数很简单,但为了完整性而包含在内。
解释 isMovingRight
函数
这段代码简单地提供了访问当前移动方向的方法:
bool InvaderUpdateComponent::isMovingRight()
{
return m_MovingRight;
}
它将用于测试是否需要检查屏幕左侧(当向左移动时)或右侧(当向右移动时)的碰撞,并允许碰撞触发对 dropDownAndReverse
函数的调用。
解释 initializeBulletSpawner
函数
我已经提到过,BulletSpawner
类是抽象的,将由 GameScreen
类实现。当调用 GameScreen
类的 initialize
函数时,这个 initializeBulletSpawner
函数将在每个入侵者上被调用。正如你所看到的,第一个参数是 BulletSpawner
实例的指针。这使每个 InvaderUpdateComponent
都能够调用 spawnBullet
函数:
void InvaderUpdateComponent::initializeBulletSpawner(
BulletSpawner* bulletSpawner, int randSeed)
{
m_BulletSpawner = bulletSpawner;
m_RandSeed = randSeed;
srand(m_RandSeed);
m_TimeBetweenShots = (rand() % 15 + m_RandSeed);
m_AccuracyModifier = (rand() % 2);
m_AccuracyModifier += 0 + static_cast <float> (
rand()) / (static_cast <float> (RAND_MAX / (10)));
}
initializeBulletSpawner
函数中的其余代码设置了使每个入侵者与其他入侵者略有不同行为的随机值。
编写 PlayerUpdateComponent
类
在 Header Files/GameObjects
过滤器中创建一个新的头文件,命名为 PlayerUpdateComponent.h
,并添加以下代码:
#pragma once
#include "UpdateComponent.h"
#include "TransformComponent.h"
#include "GameObjectSharer.h"
#include "RectColliderComponent.h"
#include "GameObject.h"
class PlayerUpdateComponent : public UpdateComponent
{
private:
string m_SpecificType = "player";
shared_ptr<TransformComponent> m_TC;
shared_ptr<RectColliderComponent> m_RCC;
float m_Speed = 50.0f;
float m_XExtent = 0;
float m_YExtent = 0;
bool m_IsHoldingLeft = false;
bool m_IsHoldingRight = false;
bool m_IsHoldingUp = false;
bool m_IsHoldingDown = false;
public:
void updateShipTravelWithController(float x, float y);
void moveLeft();
void moveRight();
void moveUp();
void moveDown();
void stopLeft();
void stopRight();
void stopUp();
void stopDown();
/****************************************************
*****************************************************
From Component interface base class
*****************************************************
*****************************************************/
string Component::getSpecificType() {
return m_SpecificType;
}
void Component::start(GameObjectSharer* gos, GameObject* self) {
m_TC = static_pointer_cast<TransformComponent>(self->
getComponentByTypeAndSpecificType(
"transform", "transform"));
m_RCC = static_pointer_cast<RectColliderComponent>(self->
getComponentByTypeAndSpecificType(
"collider", "rect"));
}
/****************************************************
*****************************************************
From UpdateComponent
*****************************************************
*****************************************************/
void update(float fps) override;
};
在 PlayerUpdateComponent
类中,我们拥有所有必要的布尔变量来跟踪玩家是否按下了键盘键,以及可以切换这些布尔值的函数。我们之前没有见过像 m_XExtent
和 M_YExtent float
类型变量这样的东西,我们将在查看它们在函数定义中的使用时解释它们。
注意,就像 BulletUpdateComponent
和 InvaderUpdateComponent
类一样,我们为这个游戏对象的变换和碰撞组件使用了共享指针。正如我们所期待的,这些共享指针在 start
函数中被初始化。
在 Source Files/GameObjects
过滤器中创建一个新的源文件,命名为 PlayerUpdate.cpp
,并添加以下代码:
#include "PlayerUpdateComponent.h"
#include "WorldState.h"
void PlayerUpdateComponent::update(float fps)
{
if (sf::Joystick::isConnected(0))
{
m_TC->getLocation().x += ((m_Speed / 100)
* m_XExtent) * fps;
m_TC->getLocation().y += ((m_Speed / 100)
* m_YExtent) * fps;
}
// Left and right
if (m_IsHoldingLeft)
{
m_TC->getLocation().x -= m_Speed * fps;
}
else if (m_IsHoldingRight)
{
m_TC->getLocation().x += m_Speed * fps;
}
// Up and down
if (m_IsHoldingUp)
{
m_TC->getLocation().y -= m_Speed * fps;
}
else if (m_IsHoldingDown)
{
m_TC->getLocation().y += m_Speed * fps;
}
// Update the collider
m_RCC->setOrMoveCollider(m_TC->getLocation().x,
m_TC->getLocation().y, m_TC->getSize().x,
m_TC->getSize().y);
// Make sure the ship doesn't go outside the allowed area
if (m_TC->getLocation().x >
WorldState::WORLD_WIDTH - m_TC->getSize().x)
{
m_TC->getLocation().x =
WorldState::WORLD_WIDTH - m_TC->getSize().x;
}
else if (m_TC->getLocation().x < 0)
{
m_TC->getLocation().x = 0;
}
if (m_TC->getLocation().y >
WorldState::WORLD_HEIGHT - m_TC->getSize().y)
{
m_TC->getLocation().y =
WorldState::WORLD_HEIGHT - m_TC->getSize().y;
}
else if (m_TC->getLocation().y <
WorldState::WORLD_HEIGHT / 2)
{
m_TC->getLocation().y =
WorldState::WORLD_HEIGHT / 2;
}
}
void PlayerUpdateComponent::
updateShipTravelWithController(float x, float y)
{
m_XExtent = x;
m_YExtent = y;
}
void PlayerUpdateComponent::moveLeft()
{
m_IsHoldingLeft = true;
stopRight();
}
void PlayerUpdateComponent::moveRight()
{
m_IsHoldingRight = true;
stopLeft();
}
void PlayerUpdateComponent::moveUp()
{
m_IsHoldingUp = true;
stopDown();
}
void PlayerUpdateComponent::moveDown()
{
m_IsHoldingDown = true;
stopUp();
}
void PlayerUpdateComponent::stopLeft()
{
m_IsHoldingLeft = false;
}
void PlayerUpdateComponent::stopRight()
{
m_IsHoldingRight = false;
}
void PlayerUpdateComponent::stopUp()
{
m_IsHoldingUp = false;
}
void PlayerUpdateComponent::stopDown()
{
m_IsHoldingDown = false;
}
在更新函数的第一个 if
块中,条件是 sf::Joystick::isConnected(0)
。当玩家将游戏手柄插入 USB 端口时,此条件返回 true。在 if
块内部,变换组件的水平和垂直位置都被改变了:
…((m_Speed / 100) * m_YExtent) * fps;
上述代码在将目标速度乘以 m_YExtent
之前将其除以 100。m_XExtent
和 m_YExtent
变量将在每一帧更新,以保存表示玩家在水平和垂直方向上移动游戏手柄摇杆的程度。值的范围是从 -100 到 100,因此上述代码的效果是当摇杆位于任何全范围或该范围的分数之一时,以全速移动变换组件;当它部分位于中心(完全不移动)和全范围之间时,则以该速度的分数移动。这意味着如果玩家选择使用游戏手柄而不是键盘,他们将能够更精细地控制飞船的速度。
我们将在第二十二章中看到更多关于游戏手柄操作细节,使用游戏对象和构建游戏。
update
函数的其余部分响应代表玩家按下的或释放的键盘键的布尔变量。
在处理游戏手柄和键盘之后,碰撞组件被移动到新位置,一系列的 if
块确保玩家飞船不会移出屏幕或超过屏幕中间的上方点。
下一个函数是 updateShipTravelWithController
函数;当控制器被插入时,它将更新每一帧拇指摇杆移动或静止的程度。
剩余的函数更新表示是否使用键盘按键来移动飞船的布尔值。请注意,更新组件不处理发射子弹。我们本来可以在这里处理它,而且有些游戏可能出于某些原因这样做。在这个游戏中,从GameInputHandler
类中处理射击子弹要直接一些。正如我们将在第二十二章中看到的那样,GameInputHandler
类将调用所有让PlayerUpdateComponent
类知道游戏手柄和键盘发生什么的函数。在前一章中,我们在GameInputHandler
类中编写了键盘响应的基本代码。
现在,让我们来编写GameObject
类,它将包含所有各种组件实例。
编码 GameObject 类
我将在这个课程中非常详细地讲解代码,因为它对于其他所有课程的工作原理至关重要。然而,我认为你们通过查看整个代码并首先研究它,也会有所收获。考虑到这一点,在Header Files/GameObjects
过滤器中创建一个新的头文件,命名为GameObject.h
,并添加以下代码:
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
#include <string>
#include "Component.h"
#include "GraphicsComponent.h"
#include "GameObjectSharer.h"
#include "UpdateComponent.h"
class GameObject {
private:
vector<shared_ptr<Component>> m_Components;
string m_Tag;
bool m_Active = false;
int m_NumberUpdateComponents = 0;
bool m_HasUpdateComponent = false;
int m_FirstUpdateComponentLocation = -1;
int m_GraphicsComponentLocation = -1;
bool m_HasGraphicsComponent = false;
int m_TransformComponentLocation = -1;
int m_NumberRectColliderComponents = 0;
int m_FirstRectColliderComponentLocation = -1;
bool m_HasCollider = false;
public:
void update(float fps);
void draw(RenderWindow& window);
void addComponent(shared_ptr<Component> component);
void setActive();
void setInactive();
bool isActive();
void setTag(String tag);
string getTag();
void start(GameObjectSharer* gos);
// Slow only use in init and start
shared_ptr<Component> getComponentByTypeAndSpecificType(
string type, string specificType);
FloatRect& getEncompassingRectCollider();
bool hasCollider();
bool hasUpdateComponent();
string getEncompassingRectColliderTag();
shared_ptr<GraphicsComponent> getGraphicsComponent();
shared_ptr<TransformComponent> getTransformComponent();
shared_ptr<UpdateComponent> getFirstUpdateComponent();
};
在前面的代码中,请务必仔细检查变量、类型、函数名及其参数。
在Source Files/GameObjects
过滤器中创建一个新的源文件,命名为GameObject.cpp
,然后研究并添加以下代码:
#include "DevelopState.h"
#include "GameObject.h"
#include <iostream>
#include "UpdateComponent.h"
#include "RectColliderComponent.h"
void GameObject::update(float fps)
{
if (m_Active && m_HasUpdateComponent)
{
for (int i = m_FirstUpdateComponentLocation; i <
m_FirstUpdateComponentLocation +
m_NumberUpdateComponents; i++)
{
shared_ptr<UpdateComponent> tempUpdate =
static_pointer_cast<UpdateComponent>(
m_Components[i]);
if (tempUpdate->enabled())
{
tempUpdate->update(fps);
}
}
}
}
void GameObject::draw(RenderWindow& window)
{
if (m_Active && m_HasGraphicsComponent)
{
if (m_Components[m_GraphicsComponentLocation]->enabled())
{
getGraphicsComponent()->draw(window,
getTransformComponent());
}
}
}
shared_ptr<GraphicsComponent> GameObject::getGraphicsComponent()
{
return static_pointer_cast<GraphicsComponent>(
m_Components[m_GraphicsComponentLocation]);
}
shared_ptr<TransformComponent> GameObject::getTransformComponent()
{
return static_pointer_cast<TransformComponent>(
m_Components[m_TransformComponentLocation]);
}
void GameObject::addComponent(shared_ptr<Component> component)
{
m_Components.push_back(component);
component->enableComponent();
if (component->getType() == "update")
{
m_HasUpdateComponent = true;
m_NumberUpdateComponents++;
if (m_NumberUpdateComponents == 1)
{
m_FirstUpdateComponentLocation =
m_Components.size() - 1;
}
}
else if (component->getType() == "graphics")
{
// No iteration in the draw method required
m_HasGraphicsComponent = true;
m_GraphicsComponentLocation = m_Components.size() - 1;
}
else if (component->getType() == "transform")
{
// Remember where the Transform component is
m_TransformComponentLocation = m_Components.size() - 1;
}
else if (component->getType() == "collider" &&
component->getSpecificType() == "rect")
{
// Remember where the collider component(s) is
m_HasCollider = true;
m_NumberRectColliderComponents++;
if (m_NumberRectColliderComponents == 1)
{
m_FirstRectColliderComponentLocation =
m_Components.size() - 1;
}
}
}
void GameObject::setActive()
{
m_Active = true;
}
void GameObject::setInactive()
{
m_Active = false;
}
bool GameObject::isActive()
{
return m_Active;
}
void GameObject::setTag(String tag)
{
m_Tag = "" + tag;
}
std::string GameObject::getTag()
{
return m_Tag;
}
void GameObject::start(GameObjectSharer* gos)
{
auto it = m_Components.begin();
auto end = m_Components.end();
for (it;
it != end;
++it)
{
(*it)->start(gos, this);
}
}
// Slow - only use in start function
shared_ptr<Component> GameObject::
getComponentByTypeAndSpecificType(
string type, string specificType) {
auto it = m_Components.begin();
auto end = m_Components.end();
for (it;
it != end;
++it)
{
if ((*it)->getType() == type)
{
if ((*it)->getSpecificType() == specificType)
{
return (*it);
}
}
}
#ifdef debuggingErrors
cout <<
"GameObject.cpp::getComponentByTypeAndSpecificType-"
<< "COMPONENT NOT FOUND ERROR!"
<< endl;
#endif
return m_Components[0];
}
FloatRect& GameObject::getEncompassingRectCollider()
{
if (m_HasCollider)
{
return (static_pointer_cast<RectColliderComponent>(
m_Components[m_FirstRectColliderComponentLocation]))
->getColliderRectF();
}
}
string GameObject::getEncompassingRectColliderTag()
{
return static_pointer_cast<RectColliderComponent>(
m_Components[m_FirstRectColliderComponentLocation])->
getColliderTag();
}
shared_ptr<UpdateComponent> GameObject::getFirstUpdateComponent()
{
return static_pointer_cast<UpdateComponent>(
m_Components[m_FirstUpdateComponentLocation]);
}
bool GameObject::hasCollider()
{
return m_HasCollider;
}
bool GameObject::hasUpdateComponent()
{
return m_HasUpdateComponent;
}
小贴士
在继续之前,请务必研究前面的代码。以下解释假设你们对变量名和类型、函数名、参数和返回类型有基本了解。
解释 GameObject 类
让我们逐个函数地查看GameObject
类,并重新打印代码,以便于讨论。
解释 update 函数
update
函数在游戏循环的每一帧为每个游戏对象调用一次。像我们的大多数其他项目一样,需要当前帧率。在update
函数内部,会进行一个测试,以查看这个GameObject
实例是否处于活动状态并且有一个更新组件。游戏对象不必有更新组件,尽管在这个项目中所有游戏对象确实都有。
接下来,update
函数遍历它拥有的所有组件,从 m_FirstUpdateComponent
开始,一直到 m_FirstUpdateComponent + m_NumberUpdateComponents
。这段代码暗示一个游戏对象可以拥有多个更新组件。这样你可以设计具有行为层的游戏对象。这种行为分层在第二十二章,使用游戏对象和构建游戏中进一步讨论。在这个项目中,所有游戏对象只有一个更新组件,因此你可以简化(并加快)update
函数中的逻辑,但我建议在阅读第二十二章,使用游戏对象和构建游戏之前保持原样。
正因为一个组件可能是我们创建的许多类型之一,所以我们创建一个临时的更新相关组件(tempUpdate
),将组件从组件向量转换为 UpdateComponent
,并调用 update
函数。UpdateComponent
类的具体派生并不重要;它将实现 update
函数,因此 UpdateComponent
类型足够具体:
void GameObject::update(float fps)
{
if (m_Active && m_HasUpdateComponent)
{
for (int i = m_FirstUpdateComponentLocation; i <
m_FirstUpdateComponentLocation +
m_NumberUpdateComponents; i++)
{
shared_ptr<UpdateComponent> tempUpdate =
static_pointer_cast<UpdateComponent>(
m_Components[i]);
if (tempUpdate->enabled())
{
tempUpdate->update(fps);
}
}
}
}
当我们在后面的部分到达 addComponent
函数时,我们将看到如何初始化各种控制变量,例如 m_FirstUpdateComponentLocation
和 m_NumberOfUpdateComponents
。
解释绘制函数
draw
函数检查游戏对象是否处于活动状态并且它有一个图形组件。如果确实如此,则检查图形组件是否启用。如果所有这些测试都成功,则调用 draw
函数:
void GameObject::draw(RenderWindow& window)
{
if (m_Active && m_HasGraphicsComponent)
{
if (m_Components[m_GraphicsComponentLocation]->enabled())
{
getGraphicsComponent()->draw(window,
getTransformComponent());
}
}
}
draw
函数的结构暗示并非每个游戏对象都必须自己绘制。我在第十九章,游戏编程设计模式 – 开始 Space Invaders ++ 游戏中提到,你可能希望游戏对象作为不可见的触发区域(没有图形组件)来响应玩家经过它们,或者作为暂时不可见的游戏对象(暂时禁用但具有图形组件)。在这个项目中,所有游戏对象都有一个永久启用的图形组件。
解释获取图形组件函数
此函数返回一个指向图形组件的共享指针:
shared_ptr<GraphicsComponent> GameObject::getGraphicsComponent()
{
return static_pointer_cast<GraphicsComponent>(
m_Components[m_GraphicsComponentLocation]);
}
getGraphicsComponent
函数允许任何拥有包含的游戏对象实例的代码访问图形组件。
解释获取变换组件函数
此函数返回一个指向变换组件的共享指针:
shared_ptr<TransformComponent> GameObject::getTransformComponent()
{
return static_pointer_cast<TransformComponent>(
m_Components[m_TransformComponentLocation]);
}
getTransformComponent
函数允许任何拥有包含的游戏对象实例的代码访问变换组件。
解释添加组件函数
addComponent
函数将在下一章中编写的工厂模式类中使用。该函数接收一个指向 Component
实例的共享指针。函数内部首先发生的事情是将 Component
实例添加到 m_Components
向量中。接下来,使用 enabled
函数启用该组件。
接下来是一系列 if
和 else if
语句,用于处理每种可能的组件类型。当识别出组件的类型时,各种控制变量被初始化,以使类中其余部分的逻辑能够正确工作。
例如,如果检测到更新组件,则初始化 m_HasUpdateComponent
、m_NumberUpdateComponents
和 m_FirstUpdateComponentLocation
变量。
作为另一个例子,如果检测到具有 rect
特定类型的碰撞器组件,则初始化 m_HasCollider
、m_NumberRectColliderComponents
和 m_FirstRectColliderComponent
变量:
void GameObject::addComponent(shared_ptr<Component> component)
{
m_Components.push_back(component);
component->enableComponent();
if (component->getType() == "update")
{
m_HasUpdateComponent = true;
m_NumberUpdateComponents++;
if (m_NumberUpdateComponents == 1)
{
m_FirstUpdateComponentLocation =
m_Components.size() - 1;
}
}
else if (component->getType() == "graphics")
{
// No iteration in the draw method required
m_HasGraphicsComponent = true;
m_GraphicsComponentLocation = m_Components.size() - 1;
}
else if (component->getType() == "transform")
{
// Remember where the Transform component is
m_TransformComponentLocation = m_Components.size() - 1;
}
else if (component->getType() == "collider" &&
component->getSpecificType() == "rect")
{
// Remember where the collider component(s) is
m_HasCollider = true;
m_NumberRectColliderComponents++;
if (m_NumberRectColliderComponents == 1)
{
m_FirstRectColliderComponentLocation =
m_Components.size() - 1;
}
}
}
注意,GameObject
类在配置或设置实际组件方面不起作用。所有这些都在下一章中我们将编写的工厂模式类中处理。
解释获取器和设置器函数
以下代码是一系列非常简单的获取器和设置器:
void GameObject::setActive()
{
m_Active = true;
}
void GameObject::setInactive()
{
m_Active = false;
}
bool GameObject::isActive()
{
return m_Active;
}
void GameObject::setTag(String tag)
{
m_Tag = "" + tag;
}
std::string GameObject::getTag()
{
return m_Tag;
}
前面的获取器和设置器函数提供了有关游戏对象的信息,例如它是否处于活动状态以及它的标签是什么。它们还允许您设置标签并告诉我们游戏对象是否处于活动状态。
解释 start
函数
start
函数非常重要。正如我们在编写所有组件时看到的那样,start
函数提供了访问任何游戏对象中任何组件的能力。当所有 GameObject
实例都由其组件组成后,将调用 start
函数。在下一章中,我们将看到这是如何发生的,以及 start
函数在每一个 GameObject
实例上被调用的时机。正如我们所见,在 start
函数中,它遍历每个组件并共享一个新的类实例,一个 GameObjectSharer
实例。这个 GameObjectSharer
类将在下一章中编写,并将提供从任何类访问任何组件的能力。我们看到了入侵者需要知道玩家的位置以及当编写各种组件时如何使用 GameObjectSharer
参数。当对每个组件调用 start
时,也会传入 this
指针,以便每个组件可以轻松访问其包含的 GameObject
实例:
void GameObject::start(GameObjectSharer* gos)
{
auto it = m_Components.begin();
auto end = m_Components.end();
for (it;
it != end;
++it)
{
(*it)->start(gos, this);
}
}
让我们继续到 getComponentByTypeAndSpecificType
函数。
解释 getComponentByTypeAndSpecificType
函数
getComponentByTypeAndSpecificType
函数有一个嵌套的 for
循环,用于查找与第一个 string
参数匹配的组件类型,然后查找第二个 string
参数中特定组件类型的匹配项。它返回一个指向基类 Component
实例的共享指针。这意味着调用代码需要确切知道返回的是哪种派生 Component
类型,以便将其转换为所需类型。这不应该是一个问题,因为他们当然请求了类型和特定类型:
// Slow only use in start
shared_ptr<Component> GameObject::getComponentByTypeAndSpecificType(
string type, string specificType) {
auto it = m_Components.begin();
auto end = m_Components.end();
for (it;
it != end;
++it)
{
if ((*it)->getType() == type)
{
if ((*it)->getSpecificType() == specificType)
{
return (*it);
}
}
}
#ifdef debuggingErrors
cout <<
"GameObject.cpp::getComponentByTypeAndSpecificType-"
<< "COMPONENT NOT FOUND ERROR!"
<< endl;
#endif
return m_Components[0];
}
这个函数中的代码相当慢,因此它打算在主游戏循环之外使用。在函数的末尾,如果已经定义了 debuggingErrors
,代码将向控制台写入错误信息。这是因为,如果执行到达这个点,意味着没有找到匹配的组件,游戏将会崩溃。控制台输出的信息应该使得错误易于查找。崩溃的原因可能是函数被调用时使用了无效的类型或特定类型。
解释 getEncompassingRectCollider
函数
getEncompassingRectCollider
函数检查游戏对象是否有碰撞体,如果有,则将其返回给调用代码:
FloatRect& GameObject::getEncompassingRectCollider()
{
if (m_HasCollider)
{
return (static_pointer_cast<RectColliderComponent>(
m_Components[m_FirstRectColliderComponentLocation]))
->getColliderRectF();
}
}
值得注意的是,如果你将此项目扩展以处理多种类型的碰撞体,那么这段代码也需要进行修改。
解释 getEncompassingRectColliderTag
函数
这个简单的函数返回碰撞体的标签。这将有助于确定正在测试碰撞的对象类型:
string GameObject::getEncompassingRectColliderTag()
{
return static_pointer_cast<RectColliderComponent>(
m_Components[m_FirstRectColliderComponentLocation])->
getColliderTag();
}
我们还有几个函数需要讨论。
解释 getFirstUpdateComponent
函数
getFirstUpdateComponent
使用 m_FirstUpdateComponent
变量来定位更新组件,并将其返回给调用代码:
shared_ptr<UpdateComponent> GameObject::getFirstUpdateComponent()
{
return static_pointer_cast<UpdateComponent>(
m_Components[m_FirstUpdateComponentLocation]);
}
现在我们将简要介绍几个获取器,然后我们就完成了。
解释最终的获取器函数
这两个剩余的函数返回一个布尔值(每个),以告知调用代码游戏对象是否有碰撞体和/或更新组件:
bool GameObject::hasCollider()
{
return m_HasCollider;
}
bool GameObject::hasUpdateComponent()
{
return m_HasUpdateComponent;
}
我们已经完整地编写了 GameObject
类。现在我们可以看看如何使用它(以及它将包含的所有组件)。
摘要
在本章中,我们已经完成了所有将游戏对象绘制到屏幕上、控制它们的行为以及通过碰撞让它们与其他类交互的代码。从本章中要吸取的最重要的一点不是任何特定基于组件的类是如何工作的,而是实体-组件系统是多么灵活。如果你想创建一个以某种方式行为的游戏对象,就创建一个新的更新组件。如果它需要了解游戏中的其他对象,可以在start
函数中获取适当的组件指针。如果它需要以某种花哨的方式绘制,比如使用着色器或动画,就在draw
函数中编写一个执行这些操作的图形组件。如果你需要多个碰撞器,就像我们在《托马斯迟到了》项目中为托马斯和鲍勃做的,这没有任何问题:编写一个新的基于碰撞器的组件。
在下一章中,我们将编写文件输入和输出系统,以及将构建所有游戏对象并将它们与组件组合的工厂类。
第二十二章:第二十一章:文件输入/输出和游戏对象工厂
本章处理 GameObject
如何进入游戏中使用的 m_GameObjects vector
。我们将探讨如何在文本文件中描述单个对象和整个关卡。我们将编写代码来解释文本,然后将值加载到一个将成为游戏对象蓝图的类中。我们还将编写一个名为 LevelManager
的类,它监督整个流程,从 InputHandler
通过 ScreenManager
发送的初始加载关卡请求开始,一直到工厂模式类从组件组装游戏对象并将其交付给 LevelManager
,最后将其整齐地打包到 m_GameObjects vector
中。
本章我们将经历的步骤如下:
-
检查我们如何在文本文件中描述游戏对象及其组件
-
编写
GameObjectBlueprint
类,其中将临时存储来自文本文件的数据 -
编写
ObjectTags
类以帮助一致且无错误地描述游戏对象 -
代码
BluePrintObjectParser
负责从文本文件中的游戏对象描述中加载数据到GameObjectBlueprint
实例 -
代码
PlayModeObjectLoader
,它将打开文本文件,并从BlueprintObjectParser
逐个接收GameObjectBlueprint
实例 -
编写
GameObjectFactoryPlayMode
类,它将从GameObjectBlueprint
实例构建GameObject
实例 -
编写
LevelManager
类,它在接收到ScreenManager
类的指令后监督整个流程 -
将代码添加到
ScreenManager
类中,以便我们可以开始使用本章我们将编写的新的系统
让我们先来具体看看如何在文本文件中描述一个游戏对象,比如一个太空侵略者或子弹,更不用说一整波的它们了。
文件输入/输出和工厂类的结构
请看以下图表,它概述了本章我们将编写的类以及 GameObject
实例的 vector
将如何与我们在 第十九章 中编写的 ScreenManager
类共享,游戏编程设计模式 – 开始太空侵略者 ++ 游戏:
前面的图示显示,存在一个GameObject
实例的vector
,它在四个类之间共享。这是通过在类的函数之间通过引用传递vector
来实现的。每个类都可以使用vector
及其内容来执行其角色。当需要将新级别加载到vector
中时,ScreenManager
类将触发LevelManager
类。正如我们在第十九章,“游戏编程设计模式 - 开始 Space Invaders ++ 游戏”中看到的,单个Screen
类及其由InputHandler
派生的类可以通过ScreenManagerRemoteControl
访问ScreenManager
。
LevelManager
类最终负责创建和共享这个vector
。PlayModeObjectLoader
将使用BlueprintObjectParser
来创建GameObjectBlueprint
实例。
GameObjectFactoryPlayMode
类将使用这些GameObjectBlueprint
实例完成GameObject
的创建过程,并在PlayModeObjectLoader
提示时将这些GameObject
实例打包到vector
中。
那么,每个GameObject
实例的不同组件、位置、大小和外观配置是从哪里来的?
我们还可以看到,有三个类可以访问一个GameObjectBlueprint
实例。这个实例由LevelManager
类创建并通过引用传递。BlueprintObjectParser
将读取level1.txt
文件,该文件包含每个游戏对象的详细信息。它将初始化GameObjectBlueprint
类的所有变量。然后PlayModeObjectLoader
将传递一个GameObject
实例的vector
的引用,并将一个完全配置的GameObjectBlueprint
实例的引用传递给GameObjectFactoryPlayMode
类。这个过程会重复,直到所有的GameObject
实例都被打包到vector
中。
你可能想知道为什么我使用了像GameObjectFactoryPlayMode
和PlayModeObjectLoader
这样稍微有些繁琐的类名。原因是,一旦你看到这个系统有多方便,你可能就会想构建工具,允许你通过拖放所需的位置来以可视化的方式设计你的级别,然后自动生成文本文件而不是手动输入。这并不特别复杂,但我不得不在某一点停止添加游戏功能。因此,你可能会最终拥有一个GameObjectFactoryDesignMode
和一个DesignModeObjectLoader
。
描述世界中的对象
我们已经在第十九章,“游戏编程设计模式 - 开始 Space Invaders ++ 游戏”中添加了world
文件夹中的level1.txt
文件。让我们讨论它的用途、未来预期用途及其内容。
首先,我想指出,射击游戏并不是演示如何在文本文件中描述游戏世界的最佳方式。原因在于游戏中只有几种类型的游戏物体,最常见的入侵者都像列队行进的士兵一样整齐排列。实际上,它们可能更有效地通过编程描述,比如在一个嵌套的for
循环中。然而,这个项目的目的是展示这些想法,而不是学习如何制作太空入侵者克隆。
看看以下文本,这是来自world
文件夹中level1.txt
文件的样本:
[START OBJECT]
[NAME]invader[-NAME]
[COMPONENT]Standard Graphics[-COMPONENT]
[COMPONENT]Invader Update[-COMPONENT]
[COMPONENT]Transform[-COMPONENT]
[LOCATION X]0[-LOCATION X]
[LOCATION Y]0[-LOCATION Y]
[WIDTH]2[-WIDTH]
[HEIGHT]2[-HEIGHT]
[BITMAP NAME]invader1[-BITMAP NAME]
[ENCOMPASSING RECT COLLIDER]invader[-ENCOMPASSING_RECT COLLIDER]
[END OBJECT]
前面的文本描述了游戏中的一个单个物体;在这种情况下,是一个入侵者。该物体以以下文本开始:
[START OBJECT]
这将通知我们即将编写的代码,正在描述一个新的物体。在文本的下一部分,我们可以看到以下内容:
[NAME]invader[-NAME]
这通知代码该物体的类型是一个入侵者。这最终将被设置为ColliderComponent
类的m_Tag
。入侵者将能被识别出其身份。接下来的文本如下:
[COMPONENT]Standard Graphics[-COMPONENT]
[COMPONENT]Invader Update[-COMPONENT]
[COMPONENT]Transform[-COMPONENT]
这告诉我们系统,此物体将添加三个组件:一个StandardGraphicsComponent
实例、一个InvaderUpdateComponent
实例和一个TransformComponent
实例。这意味着物体将以标准方式绘制,并按照我们为入侵者编写的规则进行行为。这也意味着它在游戏世界中有一个位置和大小。可能存在没有任何组件或组件较少的物体。一个不采取任何行动且不移动的物体不需要更新组件,一个不可见的物体不需要图形组件(可能只是一个不可见的碰撞器,可以触发某些动作),以及一个在世界上没有位置的物体(可能是一个调试对象)不需要变换组件。
物体的位置和大小由以下四行文本确定:
[LOCATION X]0[-LOCATION X]
[LOCATION Y]0[-LOCATION Y]
[WIDTH]2[-WIDTH]
[HEIGHT]2[-HEIGHT]
以下行文本决定了将用于此物体纹理的图形文件:
[BITMAP NAME]invader1[-BITMAP NAME]
以下行意味着该物体可以发生碰撞。一个装饰性物体,比如漂浮的云(或一只蜜蜂),可能不需要碰撞器:
[ENCOMPASSING RECT COLLIDER]invader[-ENCOMPASSING_RECT COLLIDER]
文本的最后一行将通知我们的系统,物体已经完成了自我描述:
[END OBJECT]
现在,让我们看看我们是如何描述一个子弹物体的:
[START OBJECT]
[NAME]bullet[-NAME]
[COMPONENT]Standard Graphics[-COMPONENT]
[COMPONENT]Transform[-COMPONENT]
[COMPONENT]Bullet Update[-COMPONENT]
[LOCATION X]-1[-LOCATION X]
[LOCATION Y]-1[-LOCATION Y]
[WIDTH]0.1[-WIDTH]
[HEIGHT]2.0[-HEIGHT]
[BITMAP NAME]bullet[-BITMAP NAME]
[ENCOMPASSING RECT COLLIDER]bullet[-ENCOMPASSING_RECT COLLIDER]
[SPEED]75.0[-SPEED]
[END OBJECT]
这与入侵者非常相似,但又不完全相同。子弹对象有额外的数据,例如设置速度。入侵者的速度是在 InvaderUpdateComponent
类的逻辑中设置的。我们也可以为子弹的速度做同样的事情,但这表明你可以根据特定的游戏设计要求,以尽可能多或尽可能少的细节来描述对象。同样,正如我们所期望的,子弹有一个 BulletUpdateComponent
和 [BITMAP NAME]
元素的不同值。请注意,子弹的位置被设置为 -1, -1。这意味着子弹在游戏开始时位于可玩区域之外。在下一章中,我们将看到入侵者或玩家如何在需要时将它们激活。
现在,学习以下文本,它描述了玩家的飞船:
[START OBJECT]
[NAME]Player[-NAME]
[COMPONENT]Standard Graphics[-COMPONENT]
[COMPONENT]Transform[-COMPONENT]
[COMPONENT]Player Update[-COMPONENT]
[LOCATION X]50[-LOCATION X]
[LOCATION Y]40[-LOCATION Y]
[WIDTH]3.0[-WIDTH]
[HEIGHT]2.0[-HEIGHT]
[BITMAP NAME]playership[-BITMAP NAME]
[ENCOMPASSING RECT COLLIDER]player[-ENCOMPASSING_RECT COLLIDER]
[SPEED]10.0[-SPEED]
[END OBJECT]
根据我们之前的讨论,前面的文本可能相当可预测。现在我们已经通过了这一部分,我们可以开始编码系统,该系统将解释这些对象描述并将它们转换为可用的 GameObject
实例。
编码 GameObjectBlueprint 类
在 Header Files/FileIO
过滤器中创建一个新的头文件,命名为 GameObjectBlueprint.h
,并添加以下代码:
#pragma once
#include<vector>
#include<string>
#include<map>
using namespace std;
class GameObjectBlueprint {
private:
string m_Name = "";
vector<string> m_ComponentList;
string m_BitmapName = "";
float m_Width;
float m_Height;
float m_LocationX;
float m_LocationY;
float m_Speed;
bool m_EncompassingRectCollider = false;
string m_EncompassingRectColliderLabel = "";
public:
float getWidth();
void setWidth(float width);
float getHeight();
void setHeight(float height);
float getLocationX();
void setLocationX(float locationX);
float getLocationY();
void setLocationY(float locationY);
void setName(string name);
string getName();
vector<string>& getComponentList();
void addToComponentList(string newComponent);
string getBitmapName();
void setBitmapName(string bitmapName);
string getEncompassingRectColliderLabel();
bool getEncompassingRectCollider();
void setEncompassingRectCollider(string label);
};
GameObjectBlueprint
为可能放入游戏对象中的每个属性都有一个成员变量。请注意,它并没有按组件对属性进行分类。例如,它只有宽度、高度和位置等变量的变量,它不费心将这些识别为变换组件的一部分。这些细节在工厂中处理。它还提供了获取器和设置器,以便 BlueprintObjectParser
类可以将 level1.txt
文件中的所有值打包起来,而 GameObjectFactoryPlayMode
类可以提取所有值,实例化适当的组件,并将它们添加到 GameObject
的实例中。
在 Source Files/FileIO
过滤器中创建一个新的源文件,命名为 GameObjectBlueprint.cpp
,并添加以下代码,这是为我们刚刚声明的函数的定义:
#include "GameObjectBlueprint.h"
float GameObjectBlueprint::getWidth()
{
return m_Width;
}
void GameObjectBlueprint::setWidth(float width)
{
m_Width = width;
}
float GameObjectBlueprint::getHeight()
{
return m_Height;
}
void GameObjectBlueprint::setHeight(float height)
{
m_Height = height;
}
float GameObjectBlueprint::getLocationX()
{
return m_LocationX;
}
void GameObjectBlueprint::setLocationX(float locationX)
{
m_LocationX = locationX;
}
float GameObjectBlueprint::getLocationY()
{
return m_LocationY;
}
void GameObjectBlueprint::setLocationY(float locationY)
{
m_LocationY = locationY;
}
void GameObjectBlueprint::setName(string name)
{
m_Name = "" + name;
}
string GameObjectBlueprint::getName()
{
return m_Name;
}
vector<string>& GameObjectBlueprint::getComponentList()
{
return m_ComponentList;
}
void GameObjectBlueprint::addToComponentList(string newComponent)
{
m_ComponentList.push_back(newComponent);
}
string GameObjectBlueprint::getBitmapName()
{
return m_BitmapName;
}
void GameObjectBlueprint::setBitmapName(string bitmapName)
{
m_BitmapName = "" + bitmapName;
}
string GameObjectBlueprint::getEncompassingRectColliderLabel()
{
return m_EncompassingRectColliderLabel;
}
bool GameObjectBlueprint::getEncompassingRectCollider()
{
return m_EncompassingRectCollider;
}
void GameObjectBlueprint::setEncompassingRectCollider(
string label)
{
m_EncompassingRectCollider = true;
m_EncompassingRectColliderLabel = "" + label;
}
虽然这是一个很长的类别,但这里没有我们之前没有见过的内容。设置函数接收值,这些值被复制到一个向量或变量中,而获取器允许访问这些值。
编码 ObjectTags 类
在 level1.txt
文件中描述游戏对象的方式必须精确,因为我们在本类之后将要编写的 BlueprintObjectParser
类将读取文件中的文本并寻找匹配项。例如,[START OBJECT]
标签将触发新对象的开始。如果该标签被误拼为,例如 [START OBJECR]
,那么整个系统就会崩溃,并且会出现各种错误,甚至在我们运行游戏时发生崩溃。为了避免这种情况发生,我们将为所有需要描述游戏对象的标签定义常量(程序不可更改的)string
变量。我们可以使用这些 string
变量而不是键入 [START OBJECT]
,从而大大减少出错的机会。
在 Header Files/FileIO
过滤器中创建一个新的头文件,命名为 ObjectTags.h
,并添加以下代码:
#pragma once
#include <string>
using namespace std;
static class ObjectTags {
public:
static const string START_OF_OBJECT;
static const string END_OF_OBJECT;
static const string COMPONENT;
static const string COMPONENT_END;
static const string NAME;
static const string NAME_END;
static const string WIDTH;
static const string WIDTH_END;
static const string HEIGHT;
static const string HEIGHT_END;
static const string LOCATION_X;
static const string LOCATION_X_END;
static const string LOCATION_Y;
static const string LOCATION_Y_END;
static const string BITMAP_NAME;
static const string BITMAP_NAME_END;
static const string ENCOMPASSING_RECT_COLLIDER;
static const string ENCOMPASSING_RECT_COLLIDER_END;
};
我们为将要用于描述游戏对象的每个标签都声明了一个 const string
。现在,我们可以初始化它们。
在 Source Files/FileIO
过滤器中创建一个新的源文件,命名为 ObjectTags.cpp
,并添加以下代码:
#include "DevelopState.h"
#include "objectTags.h"
const string ObjectTags::START_OF_OBJECT = "[START OBJECT]";
const string ObjectTags::END_OF_OBJECT = "[END OBJECT]";
const string ObjectTags::COMPONENT = "[COMPONENT]";
const string ObjectTags::COMPONENT_END = "[-COMPONENT]";
const string ObjectTags::NAME = "[NAME]";
const string ObjectTags::NAME_END = "[-NAME]";
const string ObjectTags::WIDTH = "[WIDTH]";
const string ObjectTags::WIDTH_END = "[-WIDTH]";
const string ObjectTags::HEIGHT = "[HEIGHT]";
const string ObjectTags::HEIGHT_END = "[-HEIGHT]";
const string ObjectTags::LOCATION_X = "[LOCATION X]";
const string ObjectTags::LOCATION_X_END = "[-LOCATION X]";
const string ObjectTags::LOCATION_Y = "[LOCATION Y]";
const string ObjectTags::LOCATION_Y_END = "[-LOCATION Y]";
const string ObjectTags::BITMAP_NAME = "[BITMAP NAME]";
const string ObjectTags::BITMAP_NAME_END = "[-BITMAP NAME]";
const string ObjectTags::ENCOMPASSING_RECT_COLLIDER =
"[ENCOMPASSING RECT COLLIDER]";
const string ObjectTags::ENCOMPASSING_RECT_COLLIDER_END
= "[-ENCOMPASSING_RECT COLLIDER]";
所有 string
变量都已初始化。现在我们可以将它们用于下一个类,并确保我们一致地描述游戏对象。
编写 BlueprintObjectParser 类
这个类将包含实际从 level1.txt
文件中读取文本的代码。它将一次解析一个对象,就像我们之前看到的起始和结束标签所标识的那样。
在 Header Files/FileIO
过滤器中创建一个新的头文件,命名为 BlueprintObjectParser.h
,并添加以下代码:
#pragma once
#include "GameObjectBlueprint.h"
#include <string>
using namespace std;
class BlueprintObjectParser {
private:
string extractStringBetweenTags(
string stringToSearch, string startTag, string endTag);
public:
void parseNextObjectForBlueprint(
ifstream& reader, GameObjectBlueprint& bp);
};
extractStringBetweenTags
私有函数将捕获两个标签之间的内容。参数是三个 string
实例。第一个 string
是来自 level1.txt
的完整文本行,而第二个和第三个是起始和结束标签,需要被丢弃。然后,两个标签之间的文本被返回给调用代码。
parseNextObjectForBlueprint
函数接收一个 ifstream
读取器,就像我们在僵尸射击游戏和托马斯迟到的游戏中使用的那样。它用于从文件中读取。第二个参数是 GameObjectBlueprint
实例的引用。该函数将使用从 level1.txt
文件中读取的值填充 GameObjectBlueprint
实例,这些值随后可以在调用代码中用于创建实际的 GameObject
。我们将在编写 PlayModeObjectLoader
类时看到这个过程,之后是 GameObjectFactoryPlayMode
类。
让我们编写我们刚刚讨论的定义。
在 Source Files/FileIO
过滤器中创建一个新的源文件,命名为 BlueprintObjectParser.cpp
,并添加以下代码:
#include "BlueprintObjectParser.h"
#include "ObjectTags.h"
#include <iostream>
#include <fstream>
void BlueprintObjectParser::parseNextObjectForBlueprint(
ifstream& reader, GameObjectBlueprint& bp)
{
string lineFromFile;
string value = "";
while (getline(reader, lineFromFile))
{
if (lineFromFile.find(ObjectTags::COMPONENT)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::COMPONENT,
ObjectTags::COMPONENT_END);
bp.addToComponentList(value);
}
else if (lineFromFile.find(ObjectTags::NAME)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::NAME, ObjectTags::NAME_END);
bp.setName(value);
}
else if (lineFromFile.find(ObjectTags::WIDTH)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::WIDTH, ObjectTags::WIDTH_END);
bp.setWidth(stof(value));
}
else if (lineFromFile.find(ObjectTags::HEIGHT)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::HEIGHT, ObjectTags::HEIGHT_END);
bp.setHeight(stof(value));
}
else if (lineFromFile.find(ObjectTags::LOCATION_X)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::LOCATION_X,
ObjectTags::LOCATION_X_END);
bp.setLocationX(stof(value));
}
else if (lineFromFile.find(ObjectTags::LOCATION_Y)
!= string::npos)
{
value = extractStringBetweenTags(
lineFromFile,
ObjectTags::LOCATION_Y,
ObjectTags::LOCATION_Y_END);
bp.setLocationY(stof(value));
}
else if (lineFromFile.find(ObjectTags::BITMAP_NAME)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::BITMAP_NAME,
ObjectTags::BITMAP_NAME_END);
bp.setBitmapName(value);
}
else if (lineFromFile.find(
ObjectTags::ENCOMPASSING_RECT_COLLIDER)
!= string::npos)
{
value = extractStringBetweenTags(lineFromFile,
ObjectTags::ENCOMPASSING_RECT_COLLIDER,
ObjectTags::ENCOMPASSING_RECT_COLLIDER_END);
bp.setEncompassingRectCollider(value);
}
else if (lineFromFile.find(ObjectTags::END_OF_OBJECT)
!= string::npos)
{
return;
}
}
}
string BlueprintObjectParser::extractStringBetweenTags(
string stringToSearch, string startTag, string endTag)
{
int start = startTag.length();
int count = stringToSearch.length() - startTag.length()
- endTag.length();
string stringBetweenTags = stringToSearch.substr(
start, count);
return stringBetweenTags;
}
parseNextObjectForBlueprint
中的代码虽然长,但很直接。一系列的 if
语句识别文本行开头的起始标签,然后将该文本行传递给 extractStringBetweenTags
函数,该函数返回的值随后被加载到 GameObjectBlueprint
引用适当的位置。请注意,当 GameObjectBlueprint
已加载所有数据时,函数会退出。这一点可以通过找到 ObjectTags::END_OF_OBJECT
来识别。
编写 PlayModeObjectLoader
类
这是将 GameObjectBlueprint
实例传递给 BlueprintObjectParser
的类。当它收到完整的蓝图后,它将它们传递给 GameObjectFactoryPlayMode
类,该类将构建 GameObject
实例并将其打包到 vector
实例中。一旦所有 GameObject
实例都构建并存储,责任将转交给 LevelManager
类,该类将控制对游戏引擎其他部分的向量访问。这是一个非常小的类,只有一个函数,但它将许多其他类连接在一起。请参考本章开头的图解以获得澄清。
在 Header Files/FileIO
过滤器中创建一个新的头文件,命名为 PlayModeObjectLoader.h
,并添加以下代码:
#pragma once
#include <vector>
#include <string>
#include "GameObject.h"
#include "BlueprintObjectParser.h"
#include "GameObjectFactoryPlayMode.h"
using namespace std;
class PlayModeObjectLoader {
private:
BlueprintObjectParser m_BOP;
GameObjectFactoryPlayMode m_GameObjectFactoryPlayMode;
public:
void loadGameObjectsForPlayMode(
string pathToFile, vector<GameObject>& mGameObjects);
};
PlayModeObjectLoader
类包含我们之前编写的类的实例,即 BluePrintObjectParser
类。它还包含我们将要编写的类的实例,即 GameObjectFactoryPlayMode
类。它有一个单独的公共函数,该函数接收一个指向包含 GameObject
实例的 vector
的引用。
现在,我们将编写 loadGameObjectsForPlayMode
函数的定义。在 Source Files/FileIO
过滤器中创建一个新的源文件,命名为 PlayModeObjectLoader.cpp
,并添加以下代码:
#include "PlayModeObjectLoader.h"
#include "ObjectTags.h"
#include <iostream>
#include <fstream>
void PlayModeObjectLoader::
loadGameObjectsForPlayMode(
string pathToFile, vector<GameObject>& gameObjects)
{
ifstream reader(pathToFile);
string lineFromFile;
float x = 0, y = 0, width = 0, height = 0;
string value = "";
while (getline(reader, lineFromFile)) {
if (lineFromFile.find(
ObjectTags::START_OF_OBJECT) != string::npos) {
GameObjectBlueprint bp;
m_BOP.parseNextObjectForBlueprint(reader, bp);
m_GameObjectFactoryPlayMode.buildGameObject(
bp, gameObjects);
}
}
}
函数接收一个 string
,这是需要加载的文件的路径。这个游戏只有一个这样的文件,但如果你愿意,可以添加更多具有不同布局、不同数量的入侵者或完全不同的游戏对象的文件。
使用 ifstream
实例逐行读取文件。在 while
循环中,使用 ObjectTags::START_OF_OBJECT
识别起始标签,并调用 BlueprintObjectParser
的 parseNextObjectForBlueprint
函数。你可能还记得,从 BlueprintObjectParser
类中,当达到 ObjectTags::END_OF_OBJECT
时,会返回完整的蓝图。
下一条代码调用 GameObjectFactoryPlayMode
类的 buildGameObject
方法,并传入 GameObjectBlueprint
实例。我们现在将编写 GameObjectFactory
类。
编写 GameObjectFactoryPlayMode
类
现在,我们将编写我们的工厂代码,该工厂将从GameObject
类以及我们在上一章中编写的所有相关组件类构建工作游戏对象。我们将大量使用智能指针,这样我们就不必担心在完成使用后删除内存。
在Header Files/FileIO
过滤器中创建一个新的头文件,命名为GameObjectFactoryPlayMode.h
,并添加以下代码:
#pragma once
#include "GameObjectBlueprint.h"
#include "GameObject.h"
#include <vector>
class GameObjectFactoryPlayMode {
public:
void buildGameObject(GameObjectBlueprint& bp,
std::vector <GameObject>& gameObjects);
};
工厂类只有一个函数,即buildGameObject
。我们已经在之前为PlayModeObjectLoader
类编写的代码中看到了调用此函数的代码。该函数接收蓝图引用以及GameObject
实例的vector
引用。
在Source Files/FileIO
过滤器中创建一个新的源文件,命名为GameObjectFactoryPlayMode.cpp
,并添加以下代码:
#include "GameObjectFactoryPlayMode.h"
#include <iostream>
#include "TransformComponent.h"
#include "StandardGraphicsComponent.h"
#include "PlayerUpdateComponent.h"
#include "RectColliderComponent.h"
#include "InvaderUpdateComponent.h"
#include "BulletUpdateComponent.h"
void GameObjectFactoryPlayMode::buildGameObject(
GameObjectBlueprint& bp,
std::vector<GameObject>& gameObjects)
{
GameObject gameObject;
gameObject.setTag(bp.getName());
auto it = bp.getComponentList().begin();
auto end = bp.getComponentList().end();
for (it;
it != end;
++it)
{
if (*it == "Transform")
{
gameObject.addComponent(
make_shared<TransformComponent>(
bp.getWidth(),
bp.getHeight(),
Vector2f(bp.getLocationX(),
bp.getLocationY())));
}
else if (*it == "Player Update")
{
gameObject.addComponent(make_shared
<PlayerUpdateComponent>());
}
else if (*it == "Invader Update")
{
gameObject.addComponent(make_shared
<InvaderUpdateComponent>());
}
else if (*it == "Bullet Update")
{
gameObject.addComponent(make_shared
<BulletUpdateComponent>());
}
else if (*it == "Standard Graphics")
{
shared_ptr<StandardGraphicsComponent> sgp =
make_shared<StandardGraphicsComponent>();
gameObject.addComponent(sgp);
sgp->initializeGraphics(
bp.getBitmapName(),
Vector2f(bp.getWidth(),
bp.getHeight()));
}
}
if (bp.getEncompassingRectCollider()) {
shared_ptr<RectColliderComponent> rcc =
make_shared<RectColliderComponent>(
bp.getEncompassingRectColliderLabel());
gameObject.addComponent(rcc);
rcc->setOrMoveCollider(bp.getLocationX(),
bp.getLocationY(),
bp.getWidth(),
bp.getHeight());
}
gameObjects.push_back(gameObject);
}
在buildGameObject
函数中发生的第一件事是创建一个新的GameObject
实例,并使用GameObject
类的setTag
函数传入正在构建的当前对象名称:
GameObject gameObject;
gameObject.setTag(bp.getName());
接下来,一个for
循环遍历m_Components
向量中的所有组件。对于找到的每个组件,一个不同的if
语句创建相应类型的组件。每个组件的创建方式各不相同,正如您所预期的,因为它们的编码方式各不相同。
以下代码创建了一个指向TransformComponent
实例的共享指针。您可以看到传递给构造函数的必要参数,即宽度、高度和位置。创建指向TransformComponent
实例的新共享指针的结果传递给了GameObject
类的addComponent
函数。现在,GameObject
实例具有其大小和在世界中的位置:
if (*it == "Transform")
{
gameObject.addComponent(make_shared<TransformComponent>(
bp.getWidth(),
bp.getHeight(),
Vector2f(bp.getLocationX(), bp.getLocationY())));
}
以下代码在需要PlayerUpdateComponent
时执行。同样,代码创建了一个指向适当类的新的共享指针,并将其传递给GameObject
实例的addComponent
函数:
else if (*it == "Player Update")
{
gameObject.addComponent(make_shared
<PlayerUpdateComponent>());
}
以下三段代码使用完全相同的技术添加InvaderUpdateComponent
、BulletUpdateComponent
或StandardGraphicsComponent
实例。注意在添加StandardGraphicsComponent
实例后添加的额外代码行,该代码行调用initialize
函数,该函数将Texture
实例(如果需要)添加到BitmapStore
单例,并准备组件以便绘制:
else if (*it == "Invader Update")
{
gameObject.addComponent(make_shared
<InvaderUpdateComponent>());
}
else if (*it == "Bullet Update")
{
gameObject.addComponent(make_shared
<BulletUpdateComponent>());
}
else if (*it == "Standard Graphics")
{
shared_ptr<StandardGraphicsComponent> sgp =
make_shared<StandardGraphicsComponent>();
gameObject.addComponent(sgp);
sgp->initializeGraphics(
bp.getBitmapName(),
Vector2f(bp.getWidth(),
bp.getHeight()));
}
以下代码所示的最后一个if
块处理添加RectColliderComponent
实例。第一行代码创建共享指针,第二行代码调用addComponent
函数将实例添加到GameObject
实例。第三行代码调用setOrMoveCollider
并传入对象的位置和大小。在这个阶段,对象准备好进行碰撞。显然,我们仍然需要编写测试碰撞的代码。我们将在下一章中这样做:
if (bp.getEncompassingRectCollider()) {
shared_ptr<RectColliderComponent> rcc =
make_shared<RectColliderComponent>(
bp.getEncompassingRectColliderLabel());
gameObject.addComponent(rcc);
rcc->setOrMoveCollider(bp.getLocationX(),
bp.getLocationY(),
bp.getWidth(),
bp.getHeight());
}
类中的以下代码行将刚刚构建的GameObject
实例添加到将与GameScreen
类共享的vector
中,并用于使游戏变得生动:
gameObjects.push_back(gameObject);
我们将要编写的下一个类使得在项目的各个类之间共享我们刚刚用GameObject
实例填充的vector
变得容易。
编写 GameObjectSharer 类
此类将有两个共享GameObject
实例的纯虚函数。
在Header Files/FileIO
筛选器中创建一个新的头文件,命名为GameObjectSharer.h
,并添加以下代码:
#pragma once
#include<vector>
#include<string>
class GameObject;
class GameObjectSharer {
public:
virtual std::vector<GameObject>& getGameObjectsWithGOS() = 0;
virtual GameObject& findFirstObjectWithTag(
std::string tag) = 0;
};
getGameObjectsWithGOS
函数返回整个GameObject
实例vector
的引用。findFirstObjectWithTag
函数返回单个GameObject
引用。当我们编写LevelManager
类时,我们将看到如何实现这些函数。
简而言之,在LevelManager
类之前,在Source Files/FileIO
筛选器中创建一个新的源文件,命名为GameObjectSharer.cpp
,并添加以下代码:
/*********************************
******THIS IS AN INTERFACE********
*********************************/
再次强调,这只是一个占位符文件,完整的功能将包含在从GameObjectSharer
继承的任何类中;在这种情况下,是LevelManager
类。
编写 LevelManager 类
LevelManager
类是我们编写的第十九章,游戏编程设计模式 – 开始 Space Invaders ++ 游戏,以及本章中我们编写的所有代码之间的连接。ScreenManager
类将包含LevelManager
类的实例,LevelManager
类将启动加载关卡(使用我们刚刚编写的所有类)并与需要它们的任何类共享GameObject
实例。
在Header Files/Engine
筛选器中创建一个新的头文件,命名为LevelManager.h
,并添加以下代码:
#pragma once
#include "GameObject.h"
#include <vector>
#include <string>
#include "GameObjectSharer.h"
using namespace std;
class LevelManager : public GameObjectSharer {
private:
vector<GameObject> m_GameObjects;
const std::string WORLD_FOLDER = "world";
const std::string SLASH = "/";
void runStartPhase();
void activateAllGameObjects();
public:
vector<GameObject>& getGameObjects();
void loadGameObjectsForPlayMode(string screenToLoad);
/****************************************************
*****************************************************
From GameObjectSharer interface
*****************************************************
*****************************************************/
vector<GameObject>& GameObjectSharer::getGameObjectsWithGOS()
{
return m_GameObjects;
}
GameObject& GameObjectSharer::findFirstObjectWithTag(
string tag)
{
auto it = m_GameObjects.begin();
auto end = m_GameObjects.end();
for (it;
it != end;
++it)
{
if ((*it).getTag() == tag)
{
return (*it);
}
}
#ifdef debuggingErrors
cout <<
"LevelManager.h findFirstGameObjectWithTag() "
<< "- TAG NOT FOUND ERROR!"
<< endl;
#endif
return m_GameObjects[0];
}
};
此类提供了两种获取充满游戏对象vector
的不同方式。一种是通过简单的调用getGameObjects
,另一种是通过getGameObjectsWithGOS
函数。后者是GameObjectSharer
类中纯虚函数的实现,它将成为一种传递访问每个游戏对象的方式,以便它能够访问所有其他游戏对象。您可能还记得从第二十章,游戏对象和组件,在GameObject
类的start
函数调用期间传递了GameObjectSharer
实例。正是在这个函数中,除了其他事情之外,入侵者能够获取到玩家的位置。
此外,还有两个私有函数:runStartPhase
,它遍历所有GameObject
实例调用start
,以及activateAllGameObjects
,它遍历并将所有GameObject
实例设置为活动状态。
此外,LevelManager
类的一部分是 loadGameObjectsForPlayMode
函数,它将触发本章描述的整个游戏对象创建过程。
LevelManger.h
文件中的最后一个函数是实现其他 GameObjectSharer
纯虚函数 findFirstObjectWithTag
的代码。这允许任何具有 GameObjectSharer
实例的类通过其标签追踪特定的游戏对象。代码遍历 vector
中的所有 GameObject
实例,并返回第一个匹配项。注意,如果没有找到匹配项,将返回一个空指针并导致游戏崩溃。我们使用 #ifdef
语句向控制台输出一些文本,告诉我们导致崩溃的原因,这样我们就不必为几个小时找不到不存在的标签而抓耳挠腮。
我们现在可以编写函数的实现代码了。
在 Source Files/Engine
过滤器中创建一个新的源文件,命名为 LevelManager.cpp
,并添加以下代码:
#include "LevelManager.h"
#include "PlayModeObjectLoader.h"
#include <iostream>
void LevelManager::
loadGameObjectsForPlayMode(string screenToLoad)
{
m_GameObjects.clear();
string levelToLoad = ""
+ WORLD_FOLDER + SLASH + screenToLoad;
PlayModeObjectLoader pmol;
pmol.loadGameObjectsForPlayMode(
levelToLoad, m_GameObjects);
runStartPhase();
}
vector<GameObject>& LevelManager::getGameObjects()
{
return m_GameObjects;
}
void LevelManager::runStartPhase()
{
auto it = m_GameObjects.begin();
auto end = m_GameObjects.end();
for (it;
it != end;
++it)
{
(*it).start(this);
}
activateAllGameObjects();
}
void LevelManager::activateAllGameObjects()
{
auto it = m_GameObjects.begin();
auto end = m_GameObjects.end();
for (it;
it != end;
++it)
{
(*it).setActive();
}
}
loadLevelForPlayMode
函数清空了 vector
,实例化了一个执行所有文件读取的 PlayModeObjectLoader
实例,并将 GameObject
实例打包到 vector
中。最后,调用 runStartPhase
函数。在 runStartPhase
函数中,所有 GameObject
实例都传递了一个 GameObjectSharer
(this
),并有机会为自己设置好,以便可以播放。记住,在 GameObject
类的 start
函数内部,每个派生 Component
实例都被赋予了访问 GameObjectSharer
的权限。参考第二十章,游戏对象和组件,以了解我们在编写 Component
类时如何使用它。
runStartPhase
函数通过调用 activateAllGameObjects
来结束,该函数遍历 vector
,对每个 GameObject
实例调用 setActive
。
getGameObjects
函数传递了 GameObject
实例的 vector
引用。
现在我们已经编写了 LevelManager
类,我们可以更新实现此接口的 ScreenManager
和 ScreenManagerRemoteControl
类。
更新 ScreenManager
和 ScreenManagerRemoteControl
类
打开 ScreenManagerRemoteControl.h
文件,取消注释所有内容,以便代码与以下内容相同。我已经突出显示了取消注释的行:
#pragma once
#include <string>
#include <vector>
#include "GameObject.h"
#include "GameObjectSharer.h"
using namespace std;
class ScreenManagerRemoteControl
{
public:
virtual void SwitchScreens(string screenToSwitchTo) = 0;
virtual void loadLevelInPlayMode(string screenToLoad) = 0;
virtual vector<GameObject>& getGameObjects() = 0;
virtual GameObjectSharer& shareGameObjectSharer() = 0;
};
接下来,打开 ScreenManager.h
,它实现了此接口并取消注释所有已注释的代码。相关的代码被缩写并突出显示如下:
...
#include "SelectScreen.h"
//#include "LevelManager.h"
#include "BitmapStore.h"
...
...
private:
map <string, unique_ptr<Screen>> m_Screens;
//LevelManager m_LevelManager;
protected:
...
...
/****************************************************
*****************************************************
From ScreenManagerRemoteControl interface
*****************************************************
*****************************************************/
...
...
//vector<GameObject>&
//ScreenManagerRemoteControl::getGameObjects()
//{
//return m_LevelManager.getGameObjects();
//}
//GameObjectSharer& shareGameObjectSharer()
//{
//return m_LevelManager;
//}
...
...
一定要取消注释包含指令、m_LevelManager
实例以及两个函数。
ScreenManager
和 ScreenManagerRemoteControl
类现在完全功能正常,getGameObjects
和 shareGameObjectSharer
函数可以被任何具有 ScreenManager
类引用的类使用。
我们现在在哪里?
到目前为止,我们 GameObject
类中的所有错误,以及所有与组件相关的类中的错误都已经消失。我们正在取得良好的进展。
此外,我们可以重新访问 ScreenManager.h
文件,取消所有注释掉的代码。
打开 ScreenManager.h
文件,取消 #include
指令的注释,如下所示:
//#include "LevelManager.h"
改成这样:
#include "LevelManager.h"
对于在 ScreenManager.h
中实现的 ScreenManagerRemoteControl
接口中的函数,也进行相同的操作。它们看起来如下所示:
void ScreenManagerRemoteControl::
loadLevelInPlayMode(string screenToLoad)
{
//m_LevelManager.getGameObjects().clear();
//m_LevelManager.
//loadGameObjectsForPlayMode(screenToLoad);
SwitchScreens("Game");
}
//vector<GameObject>&
//ScreenManagerRemoteControl::getGameObjects()
//{
//return m_LevelManager.getGameObjects();
//}
按照以下方式修改它们:
void ScreenManagerRemoteControl::
loadLevelInPlayMode(string screenToLoad)
{
m_LevelManager.getGameObjects().clear();
m_LevelManager.
loadGameObjectsForPlayMode(screenToLoad);
SwitchScreens("Game");
}
vector<GameObject>&
ScreenManagerRemoteControl::getGameObjects()
{
return m_LevelManager.getGameObjects();
}
然而,我们还没有准备好运行游戏,因为代码中仍然缺少一些类,例如 InvaderUpdateComponent
类中的 BulletSpawner
。
摘要
在本章中,我们建立了一种描述游戏关卡的方法,以及一个解释描述并构建可用的 GameObject
实例的系统。工厂模式在许多类型的编程中都有应用,而不仅仅是游戏开发。我们所使用的实现是最简单的实现,我鼓励您将工厂模式添加到您要研究和进一步开发的模式列表中。如果您希望构建一些深入和有趣的游戏,我们所使用的实现应该会很好地为您服务。
在下一章中,我们将通过添加碰撞检测、子弹生成和游戏本身的逻辑,最终让游戏变得生动起来。
第二十三章:第二十二章:使用游戏对象和构建游戏
本章是 Space Invaders ++ 项目的最终阶段。我们将学习如何使用 SFML 从游戏手柄接收输入以完成所有困难的工作,我们还将编写一个类,该类将处理入侵者与 GameScreen
类以及玩家与 GameScreen
类之间的通信。该类将允许玩家和入侵者生成子弹,但同样的技术也可以用于你游戏中不同部分之间所需的任何类型的通信,因此了解它是很有用的。游戏的最后一部分(就像往常一样)将是碰撞检测和游戏本身的逻辑。一旦 Space Invaders ++ 运行起来,我们将学习如何使用 Visual Studio 调试器,这在设计你自己的逻辑时将非常有价值,因为它允许你逐行执行代码并查看变量的值。它也是研究我们在整个项目过程中构建的模式执行流程的有用工具。
在本章中,我们将做以下事情:
-
编写生成子弹的解决方案
-
处理玩家的输入,包括使用游戏手柄
-
检测所有必要对象之间的碰撞
-
编写游戏的主要逻辑
-
了解调试并理解执行流程
让我们先从生成子弹开始。
生成子弹
我们需要一种方法,从玩家和每个入侵者那里生成子弹。这两个解决方案非常相似,但并不完全相同。我们需要一种方法,允许 GameInputHandler
在按下键盘键或游戏手柄按钮时生成子弹,并且我们需要 InvaderUpdateComponent
使用其现有的逻辑来生成子弹。
GameScreen
类有一个 vector
,其中包含所有 GameObject
实例,因此 GameScreen
是将子弹移动到位置并设置其向上或向下移动屏幕的理想候选者,这取决于谁或什么触发了射击。我们需要一种方法,让 GameInputHandler
类和 InvaderUpdateComponenet
与 GameScreen
类通信,但我们还需要将通信限制在仅生成子弹;我们不希望它们能够控制 GameScreen
类的任何其他部分。
让我们编写一个抽象类,GameScreen
可以从中继承。
编写 BulletSpawner 类
在 Header Files/GameObjects
过滤器中创建一个新的头文件,命名为 BulletSpawner.h
,并添加以下代码:
#include <SFML/Graphics.hpp>
class BulletSpawner
{
public:
virtual void spawnBullet(
sf::Vector2f spawnLocation, bool forPlayer) = 0;
};
上述代码创建了一个名为 BulletSpawner
的新类,它有一个名为 spawnBullet
的单一纯虚函数。spawnBullet
函数有两个参数。第一个参数是一个 Vector2f
实例,它将确定生成位置。实际上,正如我们很快将看到的,当子弹生成时,这个位置将根据子弹是向上屏幕移动(作为玩家子弹)还是向下屏幕移动(作为入侵者子弹)而略有调整。第二个参数是一个布尔值,如果子弹属于玩家则为真,如果属于入侵者则为假。
在 Source Files/GameObjects
过滤器中创建一个新的源文件,名为 BulletSpawner.cpp
,并添加以下代码:
/*********************************
******THIS IS AN INTERFACE********
*********************************/
小贴士
如同往常,这个 .cpp
文件是可选的。我只是想平衡一下源代码。
现在,前往 GameScreen.h
文件,因为这里是我们将实现这个类功能的地方。
更新 GameScreen.h
首先,更新以下代码中的包含指令和类声明,以使 GameScreen
类继承自 BulletSpawner
:
#pragma once
#include "Screen.h"
#include "GameInputHandler.h"
#include "GameOverInputHandler.h"
#include "BulletSpawner.h"
class GameScreen : public Screen, public BulletSpawner
{
…
…
接下来,添加一些额外的函数和变量声明,如以下代码所示,到 GameScreen.h
中:
private:
ScreenManagerRemoteControl* m_ScreenManagerRemoteControl;
shared_ptr<GameInputHandler> m_GIH;
int m_NumberInvadersInWorldFile = 0;
vector<int> m_BulletObjectLocations;
int m_NextBullet = 0;
bool m_WaitingToSpawnBulletForPlayer = false;
bool m_WaitingToSpawnBulletForInvader = false;
Vector2f m_PlayerBulletSpawnLocation;
Vector2f m_InvaderBulletSpawnLocation;
Clock m_BulletClock;
Texture m_BackgroundTexture;
Sprite m_BackgroundSprite;
public:
static bool m_GameOver;
GameScreen(ScreenManagerRemoteControl* smrc, Vector2i res);
void initialise() override;
void virtual update(float fps);
void virtual draw(RenderWindow& window);
BulletSpawner* getBulletSpawner();
新变量包括一个 int
值的 vector
,它将保存所有子弹的位置,这些子弹位于包含所有游戏对象的 vector
中。它还有一些控制变量,以便我们可以跟踪下一个要使用的子弹,子弹是针对玩家还是入侵者,以及生成子弹的位置。我们还声明了一个新的 sf::Clock
实例,因为我们想限制玩家的射击速率。最后,我们有 getBulletSpawner
函数,它将以 BulletSpawner
的形式返回对这个类的指针。这将使接收者能够访问 spawnBullet
函数,但无法访问其他内容。
现在,我们可以添加 spawnBullet
函数的实现。将以下代码添加到 GameScreen.h
文件的末尾,所有其他代码之后,但位于 GameScreen
类的闭合花括号内:
/****************************************************
*****************************************************
From BulletSpawner interface
*****************************************************
*****************************************************/
void BulletSpawner::spawnBullet(Vector2f spawnLocation,
bool forPlayer)
{
if (forPlayer)
{
Time elapsedTime = m_BulletClock.getElapsedTime();
if (elapsedTime.asMilliseconds() > 500) {
m_PlayerBulletSpawnLocation.x = spawnLocation.x;
m_PlayerBulletSpawnLocation.y = spawnLocation.y;
m_WaitingToSpawnBulletForPlayer = true;
m_BulletClock.restart();
}
}
else
{
m_InvaderBulletSpawnLocation.x = spawnLocation.x;
m_InvaderBulletSpawnLocation.y = spawnLocation.y;
m_WaitingToSpawnBulletForInvader = true;
}
}
spawnBullet
函数的实现是一个简单的 if
– else
结构。如果请求为玩家生成子弹,则执行 if
块;如果请求为入侵者生成子弹,则执行 else
块。
if
块检查自上次请求子弹以来是否至少过去了半秒钟,如果是,则将 m_WaitingToSpawnBulletForPlayer
变量设置为 true
,复制生成子弹的位置,并重新启动时钟,准备测试玩家的下一个请求。
else
块记录了入侵者子弹的生成位置,并将 m_WaitingToSpawnBulletForInvader
设置为 true
。不需要与 Clock
实例交互,因为入侵者的射击速率在 InvaderUpdateComponent
类中控制。
在实际生成子弹之前,BulletSpawner
问题的最后一部分是向 GameScreen.cpp
的末尾添加 getBulletSpawner
的定义。以下是需要添加的代码:
BulletSpawner* GameScreen::getBulletSpawner()
{
return this;
}
这将返回一个指向 GameScreen
的指针,它使我们能够访问 spawnBullet
函数。
处理玩家的输入
向 GameInputHandler.h
文件中添加更多声明,以便您的代码与以下内容匹配。我已经高亮显示需要添加的新代码:
#pragma once
#include "InputHandler.h"
#include "PlayerUpdateComponent.h"
#include "TransformComponent.h"
class GameScreen;
class GameInputHandler : public InputHandler
{
private:
shared_ptr<PlayerUpdateComponent> m_PUC;
shared_ptr<TransformComponent> m_PTC;
bool mBButtonPressed = false;
public:
void initialize();
void handleGamepad() override;
void handleKeyPressed(Event& event,
RenderWindow& window) override;
void handleKeyReleased(Event& event,
RenderWindow& window) override;
};
GameInputHandler
类现在可以访问玩家的更新组件和玩家的变换组件。这非常有用,因为它意味着我们可以告诉 PlayerUpdateComponent
实例和玩家的 TransformComponent
实例玩家正在操作哪些键盘键和游戏手柄控制。我们还没有看到这两个共享指针是如何初始化的——毕竟,GameObject
实例及其所有组件不都是打包在 vector
中吗?你可能猜到解决方案与 GameObjectSharer
有关。让我们继续编码以了解更多信息。
在 GameInputHanldler.cpp
文件中,在包含指令之后但在初始化函数之前添加 BulletSpawner
类的前向声明,如下代码所示:
#include "GameInputHandler.h"
#include "SoundEngine.h"
#include "GameScreen.h"
class BulletSpawner;
void GameInputHandler::initialize() {
…
在 GameInputHandler.cpp
文件中,将以下高亮代码添加到 handleKeyPressed
函数中:
void GameInputHandler::handleKeyPressed(
Event& event, RenderWindow& window)
{
// Handle key presses
if (event.key.code == Keyboard::Escape)
{
SoundEngine::playClick();
getPointerToScreenManagerRemoteControl()->
SwitchScreens("Select");
}
if (event.key.code == Keyboard::Left)
{
m_PUC->moveLeft();
}
if (event.key.code == Keyboard::Right)
{
m_PUC->moveRight();
}
if (event.key.code == Keyboard::Up)
{
m_PUC->moveUp();
}
if (event.key.code == Keyboard::Down)
{
m_PUC->moveDown();
}
}
注意,我们正在响应键盘按键,就像我们在本书中一直做的那样。然而,在这里,我们正在调用我们在 第二十章 中编写的 PlayerUpdateComponent
类中的函数,游戏对象和组件,以执行所需的操作。
在 GameInputHandler.cpp
文件中,将以下高亮代码添加到 handleKeyReleased
函数中:
void GameInputHandler::handleKeyReleased(
Event& event, RenderWindow& window)
{
if (event.key.code == Keyboard::Left)
{
m_PUC->stopLeft();
}
else if (event.key.code == Keyboard::Right)
{
m_PUC->stopRight();
}
else if (event.key.code == Keyboard::Up)
{
m_PUC->stopUp();
}
else if (event.key.code == Keyboard::Down)
{
m_PUC->stopDown();
}
else if (event.key.code == Keyboard::Space)
{
// Shoot a bullet
SoundEngine::playShoot();
Vector2f spawnLocation;
spawnLocation.x = m_PTC->getLocation().x +
m_PTC->getSize().x / 2;
spawnLocation.y = m_PTC->getLocation().y;
static_cast<GameScreen*>(getmParentScreen())->
spawnBullet(spawnLocation, true);
}
}
上述代码还依赖于调用 PlayerUpdateComponent
类中的函数来处理玩家释放键盘键时发生的情况。PlayerUpdateComponent
类可以停止适当的移动方向,具体取决于刚刚释放的哪个键盘键。当 空格 键释放时,getParentScreen
函数与 spawnBullet
函数链接起来以触发子弹的生成。请注意,生成坐标(spawnLocation
)是使用指向 PlayerTransformComponent
实例的共享指针计算的。
让我们了解 SFML 如何帮助我们与游戏手柄交互,然后我们可以回到 PlayerInputHandler
类以添加更多功能。
使用游戏手柄
SFML 使处理游戏手柄输入变得异常简单。游戏手柄(或操纵杆)输入由 sf::Joystick
类处理。SFML 可以处理多达八个游戏手柄的输入,但本教程将仅关注一个。
你可以将摇杆/操纵杆的位置想象成一个以左上角-100, -100 开始,右下角 100, 100 结束的 2D 图。因此,摇杆的位置可以用一个 2D 坐标来表示。以下图表通过几个示例坐标说明了这一点:
我们需要做的只是获取值,并在游戏循环的每一帧报告给PlayerUpdateComponent
类。捕获位置就像以下两行代码一样简单:
float x = Joystick::getAxisPosition(0, sf::Joystick::X);
float y = Joystick::getAxisPosition(0, sf::Joystick::Y);
零参数请求主要游戏手柄的数据。您可以使用 0 到 7 之间的值从八个游戏手柄获取输入。
我们还需要考虑其他一些因素。大多数游戏手柄,尤其是摇杆,在机械上并不完美,即使没有被触摸,也会注册到小的数值。如果我们将这些数值发送到PlayerUpdateComponent
类,那么飞船就会在屏幕上无目的地漂移。因此,我们将创建一个getAxisPosition
函数,如果任何一个轴上的值在-10 到 10 之间,我们将忽略它们。
要从游戏手柄的 B 按钮获取输入,我们使用以下行代码:
// 玩家是否按下了 B 按钮?
if (Joystick::isButtonPressed(0, 1))
{
// Take action here
}
上述代码检测当 Xbox One 游戏手柄上的 B 按钮被按下时。其他控制器会有所不同。0, 1 参数指的是主要游戏手柄和按钮编号 1。为了检测按钮何时被释放,我们需要编写一些自己的逻辑。因为我们希望在释放时而不是按下时射击子弹,我们将使用一个简单的布尔值来跟踪这个状态。让我们编写GameInputHandler
类的其余部分,看看我们如何将刚刚学到的知识付诸实践。
在GameInputHandler.cpp
文件中,将以下高亮代码添加到handleGamepad
函数中:
void GameInputHandler::handleGamepad()
{
float deadZone = 10.0f;
float x = Joystick::getAxisPosition(0, sf::Joystick::X);
float y = Joystick::getAxisPosition(0, sf::Joystick::Y);
if (x < deadZone && x > -deadZone)
{
x = 0;
}
if (y < deadZone && y > -deadZone)
{
y = 0;
}
m_PUC->updateShipTravelWithController(x, y);
// Has the player pressed the B button?
if (Joystick::isButtonPressed(0, 1))
{
mBButtonPressed = true;
}
// Has player just released the B button?
if (!Joystick::isButtonPressed(0, 1) && mBButtonPressed)
{
mBButtonPressed = false;
// Shoot a bullet
SoundEngine::playShoot();
Vector2f spawnLocation;
spawnLocation.x = m_PTC->getLocation().x +
m_PTC->getSize().x / 2;
spawnLocation.y = m_PTC->getLocation().y;
static_cast<GameScreen*>(getmParentScreen())->
getBulletSpawner()->spawnBullet(
spawnLocation, true);
}
}
我们首先定义一个死区为 10,然后继续捕捉摇杆的位置。接下来的两个if
块检查摇杆位置是否在死区内。如果是,则将适当的值设置为 0 以避免飞船漂移。然后,我们可以在PlayerUpdateComponent
实例上调用updateShipTravelWithController
函数。这就是处理过的摇杆。
下一个if
语句将布尔值设置为true
,如果游戏手柄上的 B 按钮被按下。下一个if
语句检测 B 按钮未被按下,并且布尔值被设置为true
。这表明 B 按钮刚刚被释放。
在if
块内部,我们将布尔值设置为false
,准备处理下一个按钮释放,播放射击声音,获取生成子弹的位置,并通过链式调用getmParentScreen
和getBulletSpawner
函数来调用spawnBullet
函数。
编写 PhysicsEnginePlayMode 类
这是一个将执行所有碰撞检测的类。在这个游戏中,我们有几个碰撞事件需要关注:
-
入侵者是否到达了屏幕的左侧或右侧?如果是这样,所有入侵者都需要向下移动一行,并朝相反方向前进。
-
入侵者是否与玩家相撞?随着入侵者向下移动,我们希望它们能够撞到玩家并导致玩家失去一条生命。
-
是否有入侵者的子弹击中了玩家?每次入侵者的子弹击中玩家时,我们需要隐藏子弹,准备重新使用,并从玩家那里扣除一条生命。
-
玩家的子弹是否击中了入侵者?每次玩家击中入侵者时,入侵者应该被摧毁,子弹隐藏(准备重新使用),并且玩家的分数增加。
这个类将有一个initialize
函数,GameScreen
类将调用它来准备检测碰撞,一个detectCollisions
函数,GameScreen
类将在所有游戏对象更新后对每个帧调用一次,以及三个更多函数,这些函数将从detectCollisions
函数中调用,以分离出检测不同碰撞的工作。
这三个函数是detectInvaderCollisions
、detectPlayerCollisionsAndInvaderDirection
和handleInvaderDirection
。希望这些函数的名称能清楚地说明每个函数将发生什么。
在Header Files/Engine
筛选器中创建一个新的源文件,命名为PhysicsEnginePlayMode.h
,并添加以下代码:
#pragma once
#include "GameObjectSharer.h"
#include "PlayerUpdateComponent.h"
class PhysicsEnginePlayMode
{
private:
shared_ptr<PlayerUpdateComponent> m_PUC;
GameObject* m_Player;
bool m_InvaderHitWallThisFrame = false;
bool m_InvaderHitWallPreviousFrame = false;
bool m_NeedToDropDownAndReverse = false;
bool m_CompletedDropDownAndReverse = false;
void detectInvaderCollisions(
vector<GameObject>& objects,
const vector<int>& bulletPositions);
void detectPlayerCollisionsAndInvaderDirection(
vector<GameObject>& objects,
const vector<int>& bulletPositions);
void handleInvaderDirection();
public:
void initilize(GameObjectSharer& gos);
void detectCollisions(
vector<GameObject>& objects,
const vector<int>& bulletPositions);
};
研究前面的代码,注意传递给每个函数的参数。还要注意在整个类中使用的四个成员布尔变量。此外,请注意声明了一个指向GameObject
类型的指针,这将是一个指向玩家飞船的永久引用,因此我们不需要在游戏循环的每一帧都找到代表玩家的GameObject
。
在Source Files/Engine
筛选器中创建一个新的源文件,命名为PhysicsEnginePlayMode.cpp
,并添加以下包含指令和detectInvaderCollisions
函数。研究代码,然后我们将讨论它:
#include "DevelopState.h"
#include "PhysicsEnginePlayMode.h"
#include <iostream>
#include "SoundEngine.h"
#include "WorldState.h"
#include "InvaderUpdateComponent.h"
#include "BulletUpdateComponent.h"
void PhysicsEnginePlayMode::
detectInvaderCollisions(
vector<GameObject>& objects,
const vector<int>& bulletPositions)
{
Vector2f offScreen(-1, -1);
auto invaderIt = objects.begin();
auto invaderEnd = objects.end();
for (invaderIt;
invaderIt != invaderEnd;
++invaderIt)
{
if ((*invaderIt).isActive()
&& (*invaderIt).getTag() == "invader")
{
auto bulletIt = objects.begin();
// Jump to the first bullet
advance(bulletIt, bulletPositions[0]);
auto bulletEnd = objects.end();
for (bulletIt;
bulletIt != bulletEnd;
++bulletIt)
{
if ((*invaderIt).getEncompassingRectCollider()
.intersects((*bulletIt)
.getEncompassingRectCollider())
&& (*bulletIt).getTag() == "bullet"
&& static_pointer_cast<
BulletUpdateComponent>(
(*bulletIt).getFirstUpdateComponent())
->m_BelongsToPlayer)
{
SoundEngine::playInvaderExplode();
(*invaderIt).getTransformComponent()
->getLocation() = offScreen;
(*bulletIt).getTransformComponent()
->getLocation() = offScreen;
WorldState::SCORE++;
WorldState::NUM_INVADERS--;
(*invaderIt).setInactive();
}
}
}
}
}
前面的代码遍历了所有游戏对象。第一个if
语句检查当前游戏对象是否既活跃又是入侵者:
if ((*invaderIt).isActive()
&& (*invaderIt).getTag() == "invader")
如果是活跃的入侵者,则进入另一个循环,遍历代表子弹的每个游戏对象:
auto bulletIt = objects.begin();
// Jump to the first bullet
advance(bulletIt, bulletPositions[0]);
auto bulletEnd = objects.end();
for (bulletIt;
bulletIt != bulletEnd;
++bulletIt)
下一个if
语句检查当前入侵者是否与当前子弹相撞,以及该子弹是否是由玩家发射的(我们不希望入侵者射击自己):
if ((*invaderIt).getEncompassingRectCollider()
.intersects((*bulletIt)
.getEncompassingRectCollider())
&& (*bulletIt).getTag() == "bullet"
&& static_pointer_cast<BulletUpdateComponent>(
(*bulletIt).getFirstUpdateComponent())
->m_BelongsToPlayer)
当这个测试为真时,会播放声音,子弹被移出屏幕,入侵者数量减少,玩家的分数增加,入侵者被设置为非活动状态。
现在,我们将检测玩家碰撞和入侵者的移动方向。
添加detectPlayerCollisionsAndInvaderDirection
函数,如下所示:
void PhysicsEnginePlayMode::
detectPlayerCollisionsAndInvaderDirection(
vector<GameObject>& objects,
const vector<int>& bulletPositions)
{
Vector2f offScreen(-1, -1);
FloatRect playerCollider =
m_Player->getEncompassingRectCollider();
shared_ptr<TransformComponent> playerTransform =
m_Player->getTransformComponent();
Vector2f playerLocation =
playerTransform->getLocation();
auto it3 = objects.begin();
auto end3 = objects.end();
for (it3;
it3 != end3;
++it3)
{
if ((*it3).isActive() &&
(*it3).hasCollider() &&
(*it3).getTag() != "Player")
{
// Get a reference to all the parts of
// the current game object we might need
FloatRect currentCollider = (*it3)
.getEncompassingRectCollider();
// Detect collisions between objects
// with the player
if (currentCollider.intersects(playerCollider))
{
if ((*it3).getTag() == "bullet")
{
SoundEngine::playPlayerExplode();
WorldState::LIVES--;
(*it3).getTransformComponent()->
getLocation() = offScreen;
}
if ((*it3).getTag() == "invader")
{
SoundEngine::playPlayerExplode();
SoundEngine::playInvaderExplode();
WorldState::LIVES--;
(*it3).getTransformComponent()->
getLocation() = offScreen;
WorldState::SCORE++;
(*it3).setInactive();
}
}
shared_ptr<TransformComponent>
currentTransform =
(*it3).getTransformComponent();
Vector2f currentLocation =
currentTransform->getLocation();
string currentTag = (*it3).getTag();
Vector2f currentSize =
currentTransform->getSize();
// Handle the direction and descent
// of the invaders
if (currentTag == "invader")
{
// This is an invader
if (!m_NeedToDropDownAndReverse &&
!m_InvaderHitWallThisFrame)
{
// Currently no need to dropdown
// and reverse from previous frame
// or any hits this frame
if (currentLocation.x >=
WorldState::WORLD_WIDTH –
currentSize.x)
{
// The invader is passed its
// furthest right position
if (static_pointer_cast
<InvaderUpdateComponent>((*it3)
.getFirstUpdateComponent())->
isMovingRight())
{
// The invader is travelling
// right so set a flag that
// an invader has collided
m_InvaderHitWallThisFrame
= true;
}
}
else if (currentLocation.x < 0)
{
// The invader is past its furthest
// left position
if (!static_pointer_cast
<InvaderUpdateComponent>(
(*it3).getFirstUpdateComponent())
->isMovingRight())
{
// The invader is travelling
// left so set a flag that an
// invader has collided
m_InvaderHitWallThisFrame
= true;
}
}
}
else if (m_NeedToDropDownAndReverse
&& !m_InvaderHitWallPreviousFrame)
{
// Drop down and reverse has been set
if ((*it3).hasUpdateComponent())
{
// Drop down and reverse
static_pointer_cast<
InvaderUpdateComponent>(
(*it3).getFirstUpdateComponent())
->dropDownAndReverse();
}
}
}
}
}
}
上述代码比之前的函数更长,因为我们正在检查更多的条件。在代码遍历所有游戏对象之前,它获取所有相关玩家数据的引用。这样做是为了我们不必为每个检查都这样做:
FloatRect playerCollider =
m_Player->getEncompassingRectCollider();
shared_ptr<TransformComponent> playerTransform =
m_Player->getTransformComponent();
Vector2f playerLocation =
playerTransform->getLocation();
接下来,循环遍历每个游戏对象。第一个if
测试检查当前对象是否处于活动状态,具有碰撞器,并且不是玩家。我们不希望测试玩家与自己碰撞:
if ((*it3).isActive() &&
(*it3).hasCollider() &&
(*it3).getTag() != "Player")
下一个if
测试执行实际的碰撞检测,以查看当前游戏对象是否与玩家相交:
if (currentCollider.intersects(playerCollider))
接下来,有两个嵌套的if
语句:一个处理与属于入侵者的子弹的碰撞,另一个处理与入侵者的碰撞。
接下来,代码检查每个游戏对象,看它是否击中了屏幕的左侧或右侧。请注意,使用m_NeedToDropDownAndReverse
和m_InvaderHitWallLastFrame
布尔变量,因为并不总是向量中的第一个入侵者会击中屏幕的侧面。因此,检测碰撞并触发下落和反转是在连续的帧中处理的,以确保所有入侵者都会下落并反转,无论哪个触发它。
最后,当两个条件都为true
时,调用handleInvaderDirection
。
添加handleInvaderDirection
函数,如下所示:
void PhysicsEnginePlayMode::handleInvaderDirection()
{
if (m_InvaderHitWallThisFrame) {
m_NeedToDropDownAndReverse = true;
m_InvaderHitWallThisFrame = false;
}
else {
m_NeedToDropDownAndReverse = false;
}
}
此函数只是相应地设置和取消设置布尔值,以便在detectPlayerCollisionAndDirection
函数的下一次遍历中实际上使入侵者下落并改变方向。
将initialize
函数添加到准备类以进行操作:
void PhysicsEnginePlayMode::initilize(GameObjectSharer& gos) {
m_PUC = static_pointer_cast<PlayerUpdateComponent>(
gos.findFirstObjectWithTag("Player")
.getComponentByTypeAndSpecificType("update", "player"));
m_Player = &gos.findFirstObjectWithTag("Player");
}
在上述代码中,初始化了PlayerUpdateComponent
的指针,以及玩家GameObject
的指针。这将避免在游戏循环中调用这些相对较慢的函数。
添加detectCollisions
函数,该函数将在GameScreen
类中每帧调用一次:
void PhysicsEnginePlayMode::detectCollisions(
vector<GameObject>& objects,
const vector<int>& bulletPositions)
{
detectInvaderCollisions(objects, bulletPositions);
detectPlayerCollisionsAndInvaderDirection(
objects, bulletPositions);
handleInvaderDirection();
}
detectCollisions
函数调用处理碰撞检测不同阶段的三个函数。您可以将所有代码合并到这个单一函数中,但这会使代码相当难以管理。或者,您可以将这三个大函数分离到它们自己的.cpp
文件中,就像我们在《托马斯迟到了》游戏中处理update
和draw
函数一样。
在下一节中,我们将创建PhysicsEngineGameMode
类的实例,并在GameScreen
类中使用它,以使游戏变得生动起来。
制作游戏
到本节结束时,我们将有一个可玩的游戏。在本节中,我们将向GameScreen
类添加代码,以整合我们在过去三章中编写的所有代码。要开始,通过添加一个额外的包含指令,将PhysicsEngineGameMode
的实例添加到GameScreen.h
中,如下所示:
#include "PhysicsEnginePlayMode.h"
然后,声明一个实例,如下面的代码所示:
private:
ScreenManagerRemoteControl* m_ScreenManagerRemoteControl;
shared_ptr<GameInputHandler> m_GIH;
PhysicsEnginePlayMode m_PhysicsEnginePlayMode;
…
…
现在,打开GameScreen.cpp
文件,添加一些额外的包含指令,并提前声明BulletSpawner
类,如下面的代码所示:
#include "GameScreen.h"
#include "GameUIPanel.h"
#include "GameInputHandler.h"
#include "GameOverUIPanel.h"
#include "GameObject.h"
#include "WorldState.h"
#include "BulletUpdateComponent.h"
#include "InvaderUpdateComponent.h"
class BulletSpawner;
int WorldState::WORLD_HEIGHT;
int WorldState::NUM_INVADERS;
int WorldState::NUM_INVADERS_AT_START;
接下来,在GameScreen.cpp
文件中,通过在现有代码中添加以下突出显示的代码来更新initialize
函数:
void GameScreen::initialise()
{
m_GIH->initialize();
m_PhysicsEnginePlayMode.initilize(
m_ScreenManagerRemoteControl->
shareGameObjectSharer());
WorldState::NUM_INVADERS = 0;
// Store all the bullet locations and
// Initialize all the BulletSpawners in the invaders
// Count the number of invaders
int i = 0;
auto it = m_ScreenManagerRemoteControl->
getGameObjects().begin();
auto end = m_ScreenManagerRemoteControl->
getGameObjects().end();
for (it;
it != end;
++it)
{
if ((*it).getTag() == "bullet")
{
m_BulletObjectLocations.push_back(i);
}
if ((*it).getTag() == "invader")
{
static_pointer_cast<InvaderUpdateComponent>(
(*it).getFirstUpdateComponent())->
initializeBulletSpawner(
getBulletSpawner(), i);
WorldState::NUM_INVADERS++;
}
++i;
}
m_GameOver = false;
if (WorldState::WAVE_NUMBER == 0)
{
WorldState::NUM_INVADERS_AT_START =
WorldState::NUM_INVADERS;
WorldState::WAVE_NUMBER = 1;
WorldState::LIVES = 3;
WorldState::SCORE = 0;
}
}
在initialize
函数中的前一段代码初始化了将处理所有碰撞检测的物理引擎。接下来,它遍历所有游戏对象并执行两个任务:每个if
块中一个任务。
第一个if
块测试当前游戏对象是否是子弹。如果是,则将其在游戏对象向量中的整数位置存储在m_BulletObjectLocations
向量中。记得在我们编写物理引擎时,这个vector
在碰撞检测时很有用。这个向量也将在这个类中使用,以跟踪玩家或入侵者想要射击时下一次使用的子弹。
第二个if
块检测当前游戏对象是否是入侵者,如果是,则在它的更新组件上调用initializeBulletSpawner
函数,并通过调用getBulletSpawner
函数传递一个指向BulletSpawner
的指针。现在,入侵者能够生成子弹了。
现在,我们需要向update
函数中添加一些代码来处理游戏更新阶段每帧发生的事情。以下代码中突出显示了这一点。所有新代码都放在已经存在的if(!m_GameOver)
块内:
void GameScreen::update(float fps)
{
Screen::update(fps);
if (!m_GameOver)
{
if (m_WaitingToSpawnBulletForPlayer)
{
static_pointer_cast<BulletUpdateComponent>(
m_ScreenManagerRemoteControl->
getGameObjects()
[m_BulletObjectLocations[m_NextBullet]].
getFirstUpdateComponent())->
spawnForPlayer(
m_PlayerBulletSpawnLocation);
m_WaitingToSpawnBulletForPlayer = false;
m_NextBullet++;
if (m_NextBullet == m_BulletObjectLocations
.size())
{
m_NextBullet = 0;
}
}
if (m_WaitingToSpawnBulletForInvader)
{
static_pointer_cast<BulletUpdateComponent>(
m_ScreenManagerRemoteControl->
getGameObjects()
[m_BulletObjectLocations[m_NextBullet]].
getFirstUpdateComponent())->
spawnForInvader(
m_InvaderBulletSpawnLocation);
m_WaitingToSpawnBulletForInvader = false;
m_NextBullet++;
if (m_NextBullet ==
m_BulletObjectLocations.size())
{
m_NextBullet = 0;
}
}
auto it = m_ScreenManagerRemoteControl->
getGameObjects().begin();
auto end = m_ScreenManagerRemoteControl->
getGameObjects().end();
for (it;
it != end;
++it)
{
(*it).update(fps);
}
m_PhysicsEnginePlayMode.detectCollisions(
m_ScreenManagerRemoteControl->getGameObjects(),
m_BulletObjectLocations);
if (WorldState::NUM_INVADERS <= 0)
{
WorldState::WAVE_NUMBER++;
m_ScreenManagerRemoteControl->
loadLevelInPlayMode("level1");
}
if (WorldState::LIVES <= 0)
{
m_GameOver = true;
}
}
}
在前面新代码中,第一个if
块检查是否需要为玩家生成新的子弹。如果是下一个可用的子弹,则调用GameObject
实例的BulletUpdateComponent
实例的spawnForPlayer
函数。使用m_NextBulletObject
变量和m_BulletObjectLocations
向量来识别要使用的特定GameObject
实例。第一个if
块中的剩余代码为下一次发射子弹做准备。
如果入侵者正在等待发射子弹,第二个if
块将执行。激活子弹的技术完全相同,只是使用spawnForInvader
函数,将其设置为向下移动。
接下来,有一个循环,遍历每个游戏对象。这是关键,因为在循环内部,对每个GameObject
实例调用update
函数。
在前面新代码的最后一行,调用detectCollisions
函数以查看是否有任何GameObject
实例(在其刚刚更新的位置)发生了碰撞。
最后,我们将向GameScreen.cpp
中的draw
函数添加一些代码。以下列表中突出显示了现有代码中的新代码:
void GameScreen::draw(RenderWindow & window)
{
// Change to this screen's view to draw
window.setView(m_View);
window.draw(m_BackgroundSprite);
// Draw the GameObject instances
auto it = m_ScreenManagerRemoteControl->
getGameObjects().begin();
auto end = m_ScreenManagerRemoteControl->
getGameObjects().end();
for (it;
it != end;
++it)
{
(*it).draw(window);
}
// Draw the UIPanel view(s)
Screen::draw(window);
}
在前面的代码中,简单地依次调用每个GameObject
实例上的draw
函数。现在,你已经完成了 Space Invaders ++项目,可以运行游戏了。恭喜你!
理解执行流程和调试
上一章的大部分内容都是关于代码结构。你仍然可能对哪个类实例化了哪个实例或各种函数调用的顺序有疑问和不确定性。如果有一种方法可以执行项目并从int main()
跟踪到Space Invaders ++.cpp
文件中的return 0;
的执行路径,那岂不是很有用?实际上我们可以,以下是如何做到这一点的方法。
我们现在将在探索 Visual Studio 的调试功能的同时,试图理解项目的结构。
打开Space Invaders ++.cpp
文件,找到第一行代码,如下所示:
GameEngine m_GameEngine;
上述代码是首先执行的代码行。它声明了GameEngine
类的一个实例,并启动了我们所有的努力。
右键单击前面的代码行并选择断点|插入断点。以下应该是屏幕的样子:
注意到代码行旁边有一个红色圆圈。这是一个断点。当你运行代码时,执行将在这一点暂停,我们将有一些有趣的选择可供选择。
以通常的方式运行游戏。当执行暂停时,一个箭头指示当前执行的行,如下所示:
如果你将鼠标悬停在m_GameEngine
文本上,然后点击箭头(以下截图的左上角),你将看到m_GameEngine
实例中所有成员变量及其值的预览:
让我们逐步通过代码。在主菜单中,寻找以下图标组:
如果你点击之前截图中的高亮箭头图标,它将移动到下一行代码。这个箭头图标是GameEngine
构造函数。你可以继续点击进入按钮,并在任何阶段检查任何变量的值。
如果你点击m_Resolution
的初始化,那么你会看到代码跳转到由 SFML 提供的Vector2i
类。继续点击以查看代码流通过构成我们游戏的全部步骤。
如果你想要跳到下一个函数,你可以点击以下截图所示的跳出按钮:
随着你的兴趣跟踪执行流。当你完成时,只需点击以下截图所示的停止按钮:
或者,如果你想在不逐行执行代码的情况下运行游戏,你可以点击以下截图所示的继续按钮。然而,请注意,如果断点放置在循环内部,它将在每次执行流达到断点时停止:
如果你想要从不同的起点检查代码的流程,而又不想从第一行或第一个函数开始逐行或逐个函数地点击,那么你只需要设置一个不同的断点。
你可以通过停止调试(使用停止按钮),右键单击红色圆圈,并选择删除断点来删除断点。
你可以通过在GameEngine.cpp
的update
函数的第一行代码处设置断点来开始逐步执行游戏循环。你可以将断点设置在任何地方,所以请随意探索单个组件或任何其他地方的执行流程。值得检查的关键代码部分之一是GameScreen
类的update
函数中的执行流程。为什么不试试看呢?
虽然我们刚刚探索的内容是有用且富有教育意义的,但 Visual Studio 提供的这些设施的真实目的是为了调试我们的游戏。每当你的行为不符合预期时,只需在可能引起问题的任何可能行处添加断点,逐步执行,并观察变量值。
重用代码制作不同的游戏和构建设计模式
在几次场合中,我们已经讨论了这种可能性,即我们编写的这个系统可以被重用来制作一个完全不同的游戏。我只是觉得这个事实值得充分听取。
制作不同游戏的方法如下。我已经提到,你可以将游戏对象的外观编码到从GraphicsComponent
类派生的新的组件中,你还可以将新的行为编码到从UpdateComponent
类派生的类中。
假设你想要一组具有重叠行为的游戏对象;考虑可能是一个 2D 游戏,其中敌人追捕玩家,然后在一定距离处射击玩家。
可能你可以有一个敌人类型,它会接近玩家并向玩家开枪,还有一个敌人类型会像狙击手一样从远处射击玩家。
你可以编写一个EnemyShooterUpdateComponent
类和一个EnemySniperUpdateComponent
类。你可以在start
函数期间获取玩家变换组件的共享指针,并编写一个抽象类(例如BulletSpawner
)来触发向玩家发射子弹,这样你就完成了。
然而,考虑到这两个游戏对象都会有射击和靠近玩家的代码。然后考虑,在某个阶段,你可能想要一个“格斗”敌人,它会尝试击打玩家。
当前系统也可以有多个更新组件。这样,你可以拥有一个ChasePlayerUpdateComponent
类,它会靠近玩家,以及独立的更新组件来打击、射击或狙击玩家。打击/射击/狙击组件将对追逐组件施加一些值,关于何时停止和开始追逐,然后更具体的组件(打击、射击或狙击)会在提示时间合适时攻击玩家。
正如我们已经提到的,在代码中已经内置了在多个不同的更新组件上调用update
函数的能力,尽管它从未被测试过。如果你查看GameObject.cpp
中的update
函数,你会看到以下代码:
for (int i = m_FirstUpdateComponentLocation; i <
m_FirstUpdateComponentLocation +
m_NumberUpdateComponents; i++)
{
…
}
在前面的代码中,update
函数将在所有存在的更新组件上被调用。你只需要编写它们并将它们添加到level1.txt
文件中的特定游戏对象中。使用这个系统,一个游戏对象可以有它需要的任何数量的更新组件,允许你封装非常具体的行为,并在需要时在所需的游戏对象周围共享它们。
当你想创建一个对象池,就像我们在 Space Invaders ++项目中为入侵者和子弹所做的那样,你可以比我们更有效率。为了展示如何在游戏世界中定位对象,我们为所有入侵者和子弹单独添加了它们。在一个真实的项目中,你只需设计一个代表子弹池的类型,可能是一个子弹杂志,如下所示:
[NAME]magazine of bullets[-NAME]
你可以为一群入侵者做同样的事情:
[NAME]fleet of invaders[-NAME]
然后,你会编写工厂来处理一个杂志或一个舰队,可能使用一个for
循环,并且那个稍微繁琐的文本文件将得到改进。当然,你可以在多个文本文件中设计不同数量的不同关卡,这些文本文件更有可能被命名为beach_level.txt
或urban_level.txt
。
你可能对一些类的名称感到好奇,比如PhysicsEnginePlayMode
或GameObjectFactoryPlayMode
。这表明…PlayMode
只是这些类的一个选项。
我在这里提出的建议是,即使你在关卡设计文件中使用舰队/杂志策略,随着它们的增长,它们也可能变得繁琐和难以控制。如果你能够在屏幕上查看和编辑关卡,然后将更改保存回文件,那就更好了。
你当然需要新的物理引擎规则(检测对象的点击和拖动),一个新的屏幕类型(它不会在每一帧更新),以及可能需要新的类来解释和从文本文件中构建对象。然而,重点是,实体-组件/屏幕/UI 面板/输入处理系统可以保持不变。
甚至没有任何东西阻止你设计一些全新的组件类型,例如,一个可以检测玩家移动方向的滚动背景对象,或者一个可以检测玩家站在其上并接受上下移动输入的交互式电梯对象。我们甚至可以有一个可以开关的门,或者一个当玩家触摸时检测输入并从另一个文本文件加载新级别的传送对象。这里的重点是,这些都是可以轻松集成到同一系统中的游戏机制。
我可以继续讨论这些可能性很长时间,但你可能更愿意自己制作游戏。
摘要
在本章中,我们终于完成了 Space Invaders ++ 游戏。我们编写了一种让游戏对象请求生成子弹的方法,学习了如何从游戏手柄接收输入,并且我们加入了游戏的最终逻辑,使其变得生动起来。
然而,从这个章节中汲取的最重要的一点可能是,前四章的辛勤工作将帮助你开始你的下一个项目。
在这本略显厚重的书中,还有一个最后的章节,它简短而简单,我保证。
第二十四章:第二十三章: 在你出发之前...
当你第一次打开这本书时,你可能觉得封底似乎很远。但希望这并不太难。
重点是你现在在这里,并且,希望你对如何使用 C++构建游戏有了很好的了解。
本章的目的是祝贺你取得了不错的成就,同时也指出这个页面可能不应该成为你旅程的终点。如果你像我一样,每当看到你制作的新游戏功能变得生动起来时,你可能会想要学习更多。
听到即使在这数百页之后,我们只是刚刚涉足 C++,你可能感到惊讶。即使我们涵盖的主题也可以更深入地探讨,而且还有许多——一些相当重要的——主题我们甚至没有提到。考虑到这一点,让我们看看接下来可能是什么。
如果你绝对需要正式的资格认证,那么唯一的方法是通过正规教育。这当然既昂贵又耗时,我实际上无法提供更多的帮助。
另一方面,如果你想边工作边学习,也许在你开始制作你最终会发布的游戏时,以下将讨论你可能想要做的下一步。
可能我们每个项目面临的最艰难的决定是如何结构我们的代码。在我看来,关于如何结构你的 C++游戏代码的最佳信息来源是gameprogrammingpatterns.com/
。其中一些讨论涉及本书未涵盖的概念,但大部分内容将完全可访问。如果你理解类、封装、纯虚函数和单例,请深入了解这个网站。
我已经在整本书中指出了 SFML 网站。如果你还没有访问过,请看看它:www.sfml-dev.org/
。
当你遇到你不理解(或者甚至从未听说过)的 C++主题时,最简洁、最有组织的 C++教程可以在www.cplusplus.com/doc/tutorial/
找到。
此外,还有四本你可能想了解的 SFML 书籍。它们都是好书,但适合的人群差异很大。以下是按从最注重初学者到最注重技术性的顺序排列的书籍列表:
-
由 Milcho G. Milchev 所著的《SFML 基础》:
www.packtpub.com/game-development/sfml-essentials
-
由 Maxime Barbier 所著的《SFML 蓝图》:
www.packtpub.com/game-development/sfml-blueprints
-
由 Raimondas Pupius 所著的《SFML 游戏开发实例》:
www.packtpub.com/game-development/sfml-game-development-example
-
由 Jan Haller、Henrik Vogelius Hansson 和 Artur Moreira 合著的《SFML 游戏开发》:
www.packtpub.com/game-development/sfml-game-development
你也许还想要考虑在你的游戏中添加逼真的 2D 物理效果。SFML 与 Box2d 物理引擎配合得非常完美。此网址为官方网站:box2d.org/
。以下网址提供了使用它与 C++配合的可能是最好的指南:www.iforce2d.net/
.
最后,我将毫不脸红地推广我的网站,为初学者游戏程序员提供帮助:gamecodeschool.com
.
谢谢!
最重要的是,非常感谢购买这本书,并继续制作游戏!
第二十五章:你可能还会喜欢的其他书籍
如果你喜欢这本书,你可能还会对 Packt 的其他书籍感兴趣:
通过实例学习 C++ 游戏开发
西达哈特·谢卡尔
ISBN: 978-1-78953-530-3
-
理解着色器以及如何编写基本的顶点和片段着色器
-
创建一个 Visual Studio 项目并将其与 SFML 集成
-
发现如何创建精灵动画和游戏角色类
-
为你的游戏添加音效和背景音乐
-
掌握如何将 Vulkan 集成到 Visual Studio 中
-
创建着色器并将它们转换为 SPIR-V 二进制格式
动手实践游戏深度学习
迈克尔·兰哈姆
ISBN: 978-1-78899-407-1
-
学习神经网络和深度学习的基础知识。
-
在音乐、纹理、自动驾驶汽车和聊天机器人等应用中使用高级神经网络架构。
-
理解强化学习和深度强化学习的基础知识以及如何将其应用于解决各种问题。
-
使用 Unity ML-Agents 工具包以及如何安装、设置和运行该工具包。
-
理解深度强化学习(DRL)的核心概念以及离散动作环境和连续动作环境之间的区别。
-
在各种场景中使用多种高级学习形式,从开发智能体到测试游戏。
留下评论 - 让其他读者了解你的想法
请通过在购买该书的网站上留下评论来与其他人分享你对这本书的看法。如果你从亚马逊购买了这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以通过你的无偏见意见来做出购买决定,我们也可以了解客户对我们产品的看法,我们的作者也可以看到他们对与 Packt 合作创建的标题的反馈。这只需要你几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!