继续讲解我们的第二个小游戏:太空射击游戏,本篇为上一篇文章的继续。在上一节中我给大家介绍了 Particles2D 粒子节点的相关参数以及简单的应用,这一节则介绍这个小游戏中的主要场景和关键的代码实现。
PS :在写文章的这两天, Godot 官方又紧凑地发布了第四个 Alpha 版本,大家可以到这里下载: Godot 3.1 Alpha4 ,本以为圣诞节前会发布第一个 Beta 版本,目前来看还会有第五个 Alpha 版本, Beta 版本肯定要等到 2019 年啦!嗯,热情期待中……
主要内容:太空射击游戏场景与代码(下篇)
阅读时间: 12 分钟
永久链接: http://liuqingwen.me/blog/2018/12/25/introduction-of-godot-3-part-11-introduce-the-particles-system-and-make-a-shooter-game-part-2/
系列主页: http://liuqingwen.me/blog/introduction-of-godot-series/
太空射击游戏的场景主要分为:玩家、敌人(外星人和岩石)、子弹、 UI 界面 、入口主场景等,每个场景的构造都很简单,所应用到的几个新节点我也在上一篇文章中作了简短的介绍,其他节点相信看过我本系列文章的朋友都应该很熟悉啦,哈哈。 ?
提醒大家的是,子弹场景有两个: Bullet.tscn
和 EnemyBullet.tscn
,从名字可以看出来,一个是用于玩家发射的子弹,一个是敌人发射的子弹,他们除了子弹的图片也就是外观不同之外,其他部分,包括代码都是完全一样的,因为 EnemyBullet.tscn
就是直接从 Bullet.tscn
继承实例化而来。(如何实例化一个场景?还记得 ? 这个图标吗?找一找吧。)
浏览一下所有场景“构造图”,接下来简单地一一介绍下所有场景及其核心代码部分。
首先是子弹场景,它比较特殊,有两个。子弹场景主要用于玩家和敌人发射的子弹,每颗子弹在发射后会一直往一个方向飞行,飞行过程中检查是否与其他对象相撞,然后在代码中做出相应的处理:
extends Area2D
signal destroy_object(type)
# 子弹种类:玩家、敌人
export(String, 'player', 'enemy') var type = 'player'
var _velocity = Vector2() # 子弹速度
func _process(delta):
self.position += _velocity * delta
func _on_Bullet_area_entered(area):
# 敌人的子弹击中玩家
if area.is_in_group('player') && type == 'enemy':
area.destroy()
self.queue_free()
# 子弹击中敌人,对玩家子弹和敌人子弹处理不同
elif area.is_in_group('enemy') || area.is_in_group('rock'):
if type == 'player':
area.destroy()
var objectType = 'enemy' if area.is_in_group('enemy') else 'rock'
self.emit_signal('destroy_object', objectType)
if ! (area.is_in_group('enemy') && type == 'enemy'):
self.queue_free()
# 敌人的子弹和玩家子弹相撞
elif area.is_in_group('bullet') && area.type != type:
area.queue_free()
self.queue_free()
# 子弹飞出屏幕
func _on_VisibilityNotifier2D_screen_exited():
self.queue_free()
# 设置子弹速度
func start(velocity):
velocity = velocity
那么如何在玩家和敌人场景中分别使用这两个子弹场景呢?只需要在 Player 脚本以及 Alien 脚本代码中,添加一句代码即可: export(PackedScene) var bulletScene
,也就是把各自要用到的子弹场景暴露为显示在编辑器中的变量,这样我们就可以直接拖拽相对应的子弹场景到各自的 bulletScene
属性中。
一个场景(节点)应用到多个场合在游戏中是很常见的,对于子弹场景除了我所采用的这种处理方式,还有另外一种常见的方法:只需要设置一个 Bullet.tscn
子弹场景,然后在代码中创建子弹的时候,动态设置子弹的材质就可以了。这样一个子弹场景就能轻松地被玩家和敌人分别使用,伪代码如下:
var playerBulletImageTexture = load('res://Assets/Images/player_bullet.png');
var enemyBulletImageTexture = load('res://Assets/Images/enemy_bullet.png');
func setType(type):
match type:
'player': $Sprite.texture = playerBulletImageTexture;
'enemy': $Sprite.texture = enemyBulletImageTexture;
关于资源加载函数 load()
我想在后面会继续讨论。一般游戏中会优先使用第二种方式,但是第一种方式更加适合新手,而且扩展性也更好,比如我想在敌人的子弹场景中再加一些其他的效果,让它变得更酷,这都是非常方便且直接的,另外结合 export(PackedScene)
更可以轻松的拖拽其他场景到对应的场景属性下,非常方便,大家多多感受下。
关于背景图片我已经在上一篇文章中说明过了,不过并没有详细阐述其原理,也没有提供任何代码,其实背景场景的代码是最少的:
extends ParallaxBackground
export(float) var scrollSpeed = 50
func _process(delta):
# 手动控制背景滚动
self.scroll_offset.y -= scrollSpeed * delta
然后参考一下其相关节点的设置你就能明白其中的道理:
前面两个节点很好理解,实际开发中,对于 ParallaxBackground 背景节点,我们一般会应用于有摄像机节点的游戏中,这样背景会自动跟随摄像机滚动,在 2D 游戏中我们可以设置多层背景,比如靠近玩家的树木、远离玩家的山岭、最远方的太阳或者月亮等,很显然,越远的物体滚动速度越慢,也就是 Scale 属性值越小,越近则滚动越快,大家可以结合之前的图片体会一下。那么,像本游戏中没有摄像机该如何处理呢?依然很简单,如上代码,手动设置背景的滚动属性就可以啦。
游戏中敌人主要有两种,一种是外星人,另一种是坠落的岩石,脚本代码也都很好理解,这里我给敌人添加了一些有趣的随机元素,它们可以水平移动并且随机发射子弹,核心代码如下:
# 移动并发射,生命周期内无限循环
func _moveAndShoot():
# 第一次移动,不发射子弹
var nextMovement = rand_range(0.5, 1.5)
yield(self.get_tree().create_timer(nextMovement), "timeout")
# 2/3几率发生水平移动,否则只做垂直运动
var shouldMove = randi() % 3 >= 1
if shouldMove:
_hMovement = randi() % 3 - 1
nextMovement = rand_range(1.0, 1.5)
yield(self.get_tree().create_timer(nextMovement, false), "timeout")
_hMovement = 0
# 如果在屏幕范围内,则发射子弹
if _isInShootableArea():
_shoot()
# 继续下一轮操作
_moveAndShoot()
# 省略代码……
这是一个无限循环:外星人进入游戏场景后,随机飞行一段时间,随后有一定的概率发生水平移动,接下来判断外星人是否在屏幕范围内,在范围内则发射一颗子弹。方法中我使用了很多随机时间节点,也是为了丰富游戏场景,让游戏稍微有点挑战性吧。
对于岩石场景的代码我就不贴出来了,岩石只有滚动和一定大小的随机缩放,代码很简单,不再啰嗦。
爆炸场景使用了 Particles2D 粒子节点,一个爆炸场景我使用在了这三个地方:岩石爆炸、敌人爆炸以及玩家爆炸。他们的处理方式稍微不同,这里可以从代码中看出来:
# 爆炸对象的类型:岩石、敌人、玩家
var type = 'rock' setget _setType
func _ready():
match type:
'rock':
self.amount = 40
_audioPlayer.stream = rockAudio
'player':
# 延长玩家爆炸特效的时长
_lifeTimer.wait_time = 2.0
_audioPlayer.stream = playerAudio
'alien':
self.amount = 50
_audioPlayer.stream = alienAudio
_audioPlayer.pitch_scale = 2.0
_audioPlayer.play()
# 省略代码……
嗯,区别在于三种爆炸的音效不同,粒子数量稍微有点差别而已。关于粒子我在上篇文章中已经详细讲述,如果有不清楚的,大家可以下载源码参考一下。 ?
玩家场景就非常熟悉啦,主要是控制玩家的移动,还有子弹的发射:
# 射击函数
func _shoot():
if bulletScene == null:
return
# 生成一个子弹对象
var bullet = bulletScene.instance()
# 设置子弹的全局位置
bullet.position = _shootPoint.global_position
# 调用子弹的公开方法,设置速度
bullet.start(Vector2(0, - bulletSpeed))
# 连接子弹的信号
bullet.connect('destroy_object', self, '_on_Bullet_destroy_object')
# 把子弹添加到游戏root根节点
self.get_tree().get_root().add_child(bullet)
# 音效
_shootSound.play()
# 省略代码……
这里核心代码在于 self.get_tree().get_root().add_child(bullet)
这一句,可以看出来,我把发射子弹后生成的子弹节点添加到了游戏的根节点 root
下,这样保证发射出去的子弹和玩家没有任何关系,不会发生内存泄漏。
主场景是所有子场景和代码的组合,主要负责游戏的整体控制,关键代码在于生成并添加当前关卡的所有敌人,包括岩石和外星人,另外在 _process(delta)
方法中还会不断地判断敌人是否已经被消灭完或者游戏是否已经结束。
var _isGameOver = false # 游戏是否结束
var _score = 0 # 分数
var _currentWave = 0 # 敌人波数
var _totalAliens = 0 # 敌人总数
var _totalRocks = 0 # 岩石总数
var _enemyCount = -1 # 计数:敌人+岩石
func _process(delta):
# 如果计数为0,且所有敌人被移除则进入下一波
if !_isGameOver && _enemyCount == 0 && _enemyContainer.get_child_count() == 0:
_enemyCount = -1
_nextLevel()
# 如果游戏结束,且所有敌人被移除,显示开始按钮
elif _isGameOver && _enemyContainer.get_child_count() == 0:
_ui.showStartButton()
# (重新)开始游戏
func _restart():
_isGameOver = false
_score = 0
_ui.updateScore(_score)
_currentWave = 0
_audioPlayer.play()
# 添加玩家到当前场景
if playerScene != null:
var player = playerScene.instance()
player.connect('game_over', self, '_on_Player_game_over')
player.connect('score_updated', self, '_on_Player_score_updated')
player.position = _startPosition
self.add_child(player)
# 开启第一关
_nextLevel()
# 消灭(避开)所有敌人,进入下一关
func _nextLevel():
_currentWave += 1
_ui.updateWave(_currentWave)
yield(self.get_tree().create_timer(3.0), 'timeout')
_ui.hideMessage()
_calculateEnemies()
_enemyCount = _totalAliens + _totalRocks
_generateRocks()
_generateAliens()
# 每隔一定时间(随机)生成岩石
func _generateRocks():
if rockScene == null:
return
for i in range(_totalRocks):
if _isGameOver:
return
var rock = rockScene.instance()
var yPosition = rand_range(-40, -100)
rock.position = Vector2(80 * (randi() % 6) + 40, yPosition)
_enemyContainer.add_child(rock)
_enemyCount -= 1
var nextTime = rand_range(0.5, 1.5)
yield(self.get_tree().create_timer(nextTime, false), 'timeout')
# 每隔一定时间(随机)生成敌人
func _generateAliens():
if alienScene == null:
return
for i in range(_totalAliens):
if _isGameOver:
return
var alien = alienScene.instance()
var yPosition = rand_range(-40, -100)
alien.position = Vector2(60 * (randi() % 5) + 80, yPosition)
_enemyContainer.add_child(alien)
_enemyCount -= 1
var nextTime = rand_range(1.0, 2.5)
yield(self.get_tree().create_timer(nextTime, false), 'timeout')
# 根据当前关卡得出敌人数量
func _calculateEnemies():
_totalAliens = _currentWave * 2 + 3
_totalRocks = _currentWave * 3 + 5
# 省略代码……
同样省略了一些代码,这里核心函数是 _nextLevel()
方法,方法里又调用了另外两个重要方法: _generateRocks()
和 _generateAliens()
,通过大量使用 yield
关键字创建时间计时器,这样让游戏更加随机性,让太空显得更加真实。
好啦,看一下我们的成果吧,最终效果一览:
大家可以自己尝试做这么个小游戏,也可以直接下载源码然后运行,对于热爱游戏的朋友,我觉得不应该只停留在“ play ”上,这个游戏可以做得更加有趣,你觉得呢?所以,我建议新手朋友们可以继续尝试尝试以下几点完善:
只是自己的一点点想法,哈哈。
这个小游戏的制作就此结束啦,总结一下本篇上下文的主要知识点:
本篇的 Demo 以及相关代码已经上传到 Github ,地址: https://github.com/spkingr/Godot-Demos ,最后,原创不易,希望大家喜欢吧! ?
我的博客地址: http://liuqingwen.me ,欢迎关注我的微信公众号: