一、前言
在上一篇文章中,我們一起學(xué)習(xí)探討了幾個(gè)常用的新節(jié)點(diǎn),也順便了解一下 GDScript 腳本中幾個(gè)重要關(guān)鍵字的用法,最后總結(jié)了我個(gè)人認(rèn)為比較實(shí)用的幾個(gè)所謂“最佳實(shí)踐”,寫(xiě)了這么多的目的就是為了本篇和下一篇服務(wù)的:我們使用 Godot 3.1 Alpha2 版本制作一個(gè)小游戲。
這個(gè)游戲非常簡(jiǎn)單,網(wǎng)上也有不少類(lèi)似的案例,本來(lái)打算只需要上下兩篇文章即可,后面發(fā)現(xiàn)加上代碼后整篇文章顯得“篇幅過(guò)長(zhǎng)”,如果通過(guò)刪減一些代碼來(lái)縮短篇幅的話(huà),對(duì)新手又很不友好,所以我再加一篇,分為“上-中-下”三篇吧。
溫馨提示:中篇以及下篇內(nèi)容中的代碼會(huì)比較多,如果對(duì)這個(gè)游戲感興趣,而且已經(jīng)入門(mén)的話(huà),我推薦直接到我的 Github 倉(cāng)庫(kù)下載源碼運(yùn)行查看即可,或者遇到了問(wèn)題再來(lái)翻閱此文更合適。 ?
主要內(nèi)容:分析并制作一個(gè)完整的小游戲(中篇)
閱讀時(shí)間: 12 分鐘
永久鏈接: http://liuqingwen.me/blog/2018/12/05/introduction-of-godot-3-part-10-introduce-some-node-types-and-make-a-new-game-part-2/
系列主頁(yè): http://liuqingwen.me/blog/tags/Godot/
二、正文
本篇目標(biāo)
了解學(xué)習(xí)游戲中的幾個(gè)主要場(chǎng)景的制作
編寫(xiě)代碼實(shí)現(xiàn)游戲中相關(guān)功能的邏輯
完整游戲項(xiàng)目的一個(gè)開(kāi)發(fā)流程
主要的場(chǎng)景
這是一個(gè)簡(jiǎn)單的“金幣收集小游戲”,游戲設(shè)計(jì)的主要思路和玩法大致如下:
玩家可以在自由的世界里隨處奔跑,遇到心愛(ài)的金幣可以盡收囊中
玩家要避免被仙人掌刺傷,這也是游戲的唯一實(shí)體障礙物
每個(gè)關(guān)卡有超時(shí)時(shí)間設(shè)計(jì),超時(shí)游戲結(jié)束,規(guī)定時(shí)間內(nèi)收集完金幣可進(jìn)入下一關(guān)
每關(guān)隨機(jī)冒出一個(gè)特殊“能量幣”,玩家收集能量能夠延長(zhǎng)超時(shí)時(shí)間
嗯,時(shí)間緊迫,上車(chē),趕緊出發(fā)!
- Player 玩家子場(chǎng)景
玩家子場(chǎng)景是這個(gè)項(xiàng)目的最核心游戲元素,可以說(shuō)是小游戲的靈魂所在。玩家子場(chǎng)景的制作非常簡(jiǎn)單:以碰撞體 Area2D 作為根節(jié)點(diǎn),添加一個(gè) Sprite 圖片精靈、一個(gè) CollisionShape2D 繪制碰撞區(qū)域、 AnimationPlayer 節(jié)點(diǎn)制作動(dòng)畫(huà)以及一個(gè) AudioStreamPlayer 音頻流播放節(jié)點(diǎn)。如果對(duì)這些節(jié)點(diǎn)的使用不熟悉,可以參考我之前的文章。
另外,因?yàn)槲野淹婕业膭?dòng)畫(huà)圖片制作成了一個(gè) SpriteSheet 精靈圖集,所以制作動(dòng)畫(huà)的時(shí)候需要注意圖片的顯示區(qū)域,玩家有三個(gè)動(dòng)畫(huà)狀態(tài),都比較簡(jiǎn)單,參考如下:
- Coin/Cactus/Power 金幣/障礙物/能量子場(chǎng)景
我把這三個(gè)小場(chǎng)景放到一起討論,原因是它們的結(jié)構(gòu)非常簡(jiǎn)單且很相似,都是為游戲中的“玩家”服務(wù)。三個(gè)子場(chǎng)景的制作一目了然,功能單一,相互獨(dú)立,這也符合我們的最佳實(shí)踐原則之盡量保持場(chǎng)景的獨(dú)立性。另外,在對(duì)游戲資源的管理中,我把這三個(gè)場(chǎng)景以及場(chǎng)景的相關(guān)資源(圖片)放在了 Items 一個(gè)文件夾下。
需要注意的是:能量幣場(chǎng)景中的 LifeTimer 時(shí)間節(jié)點(diǎn)表示金幣在規(guī)定時(shí)間內(nèi)會(huì)自動(dòng)消失,而能量幣的出現(xiàn)時(shí)間并不由自己控制,這里不要混淆了,后面在代碼中會(huì)有介紹。
- UI 界面元素
控件子場(chǎng)景主要用于界面顯示,主要有:金幣數(shù)量、剩余時(shí)間、開(kāi)始按鈕、文字信息顯示等。這里我使用了 MarginContainer 容器配合 HBoxContainer/VBoxContainer 來(lái)對(duì)界面元素進(jìn)行排版。提醒新手朋友們:設(shè)置 MarginContainer 的邊距需要在 Custom Constants 屬性下進(jìn)行設(shè)置。
另外 UI 子場(chǎng)景也用于接收玩家的鍵盤(pán)輸入,控制游戲的一些基本邏輯:開(kāi)始、暫停、重試等,這些我們都會(huì)在代碼中具體實(shí)現(xiàn)。
- 游戲主場(chǎng)景
這是游戲中最重要的場(chǎng)景了,也是包含并協(xié)調(diào)多個(gè)子場(chǎng)景的根場(chǎng)景。游戲的主場(chǎng)景中可以手動(dòng)添加其他的節(jié)點(diǎn)或者子場(chǎng)景,也可以通過(guò)代碼添加任意多個(gè)子場(chǎng)景,比如金幣。同時(shí),主場(chǎng)景負(fù)責(zé)并處理每個(gè)子場(chǎng)景之間通信鏈接,作為一個(gè)總指揮讓每個(gè)子場(chǎng)景各司其職,及時(shí)得到并處理各自的相關(guān)任務(wù)。
值得注意的是:我把障礙物場(chǎng)景( Cactus )作為子節(jié)點(diǎn)放在了 Path2D 路徑節(jié)點(diǎn)之下,也就是圖中的藍(lán)色路徑。場(chǎng)景中的 CoinContainer 為一個(gè)空節(jié)點(diǎn),作為動(dòng)態(tài)生成的金幣節(jié)點(diǎn)的容器。
邏輯與代碼
在 Godot 中每一個(gè)節(jié)點(diǎn)都能添加代碼,而且最多只能關(guān)聯(lián)一個(gè)腳本,一般子場(chǎng)景的功能相對(duì)單一,我們優(yōu)先考慮給子場(chǎng)景的根節(jié)點(diǎn)添加一個(gè)腳本,而其他節(jié)點(diǎn)可以視需求添加,需要說(shuō)明的是:子場(chǎng)景中需要暴露出來(lái)的供其它場(chǎng)景調(diào)用的公開(kāi)方法最好寫(xiě)在根節(jié)點(diǎn)的腳本代碼中。
另外,實(shí)現(xiàn)游戲的相關(guān)功能以及邏輯代碼并不是只有唯一的一種方式,你完全可以根據(jù)自己的需求、設(shè)計(jì)原則、游戲規(guī)則等來(lái)進(jìn)行代碼編寫(xiě)。 ?
說(shuō)明:這個(gè)小游戲的靈感和圖片資源都來(lái)源于《 Godot Engine Game Development Projects 》這本書(shū),我參考了它的代碼,但是我的設(shè)計(jì)方式與之稍有不同,比如在處理玩家和金幣碰撞的邏輯上有兩種方式,是在 Player 玩家場(chǎng)景中檢測(cè)碰撞并調(diào)用 Coin 的方法,還是在 Coin 金幣場(chǎng)景中檢測(cè)碰撞并調(diào)用 Player 的方法,此書(shū)的作者采用了前者,而我選擇了后者。我的觀(guān)點(diǎn)是:游戲元素為玩家服務(wù),玩家不需要關(guān)心游戲世界里有哪些元素。當(dāng)然,運(yùn)行結(jié)果完全相同。
接下面我把游戲中的主要代碼貼出來(lái)供大家參考閱讀,如果遇到不懂的地方可以隨時(shí)翻閱我之前的文章,或者直接在 Godot 編輯器中按 F4 搜索查看相關(guān)的 API 說(shuō)明,相信配合我在腳本中的注釋?zhuān)炊a的具體邏輯沒(méi)什么問(wèn)題。 ?
- Player.gd
extends Area2D
signal group
signal coin_collected(count) # 金幣收集信號(hào)
signal power_collected(buffer) # 能量幣收集信號(hào)
signal game_over() # 游戲結(jié)束信號(hào)
export
export(int) var moveSpeed = 320
export(AudioStream) var coinSound = null
export(AudioStream) var hurtSound = null
export(AudioStream) var powerSound = null
onready
onready var _animationPlayer = AudioStreamPlayer
onready var _sprite = $Sprite
enum, constant
variable
var isControllable = true setget _setIsControllable # 是否允許玩家被控制
var _coins = 0 # 當(dāng)前關(guān)卡所收集金幣的數(shù)量
var _boundary = {minX = 0, minY = 0, maxX = 0, maxY = 0} # 移動(dòng)范圍
functions
func _ready():
var scale = _sprite.scale
var rect = _sprite.get_rect()
# 設(shè)置玩家能移動(dòng)的上下左右最大范圍
_boundary.minX = - rect.position.x * scale.x
_boundary.minY = - rect.position.y * scale.y
_boundary.maxX = ProjectSettings.get('display/window/size/width') - (rect.position.x + rect.size.x) * scale.x
_boundary.maxY = ProjectSettings.get('display/window/size/height') - (rect.position.y + rect.size.y) * scale.y
func _process(delta):
# 根據(jù)玩家鍵盤(pán)輸入設(shè)置玩家的移動(dòng)方向和速度
var hDir = int(Input.is_action_pressed('right')) - int(Input.is_action_pressed('left'))
var vDir = int(Input.is_action_pressed('down')) - int(Input.is_action_pressed('up'))
var velocity = Vector2(hDir, vDir).normalized()
self.position += velocity * moveSpeed * delta
self.position.x = clamp(self.position.x, _boundary.minX, _boundary.maxX)
self.position.y = clamp(self.position.y, _boundary.minY, _boundary.maxY)
if hDir != 0:
_sprite.flip_h = hDir < 0
if hDir != 0 || vDir != 0:
_animationPlayer.current_animation = 'run'
else:
_animationPlayer.current_animation = 'idle'
isControllable屬性的set方法
func _setIsControllable(value):
if isControllable != value:
isControllable = value
self.set_process(isControllable)
_animationPlayer.current_animation = 'idle' if ! isControllable else _animationPlayer.current_animation
重新開(kāi)始的方法,傳遞一個(gè)玩家初始位置
func restart(pos):
_coins = 0
self.position = pos
收集金幣方法,傳遞收集金幣數(shù)量
func collectCoin(num = 1):
_coins += num
_audioPlayer.stream = coinSound
_audioPlayer.play()
self.emit_signal('coin_collected', _coins)
收集到能量調(diào)用的方法
func collectPower(buffer):
_audioPlayer.stream = powerSound
_audioPlayer.play()
self.emit_signal('power_collected', buffer)
玩家受到傷害時(shí)用方法
func hurt():
_animationPlayer.current_animation = 'hurt'
_audioPlayer.stream = hurtSound
_audioPlayer.play()
self.set_process(false)
self.emit_signal('game_over')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
玩家場(chǎng)景的代碼部分相對(duì)較多,在此我特意標(biāo)明了我的源碼編寫(xiě)習(xí)慣,一般保持良好的代碼風(fēng)格是有利于游戲的調(diào)試和功能的擴(kuò)展的,代碼中我習(xí)慣的編碼順序是:
signal/group 信號(hào)、分組寫(xiě)代碼文件最前
export 接著是顯示在編輯器中可編輯的相關(guān)變量
onready 主要表示一些對(duì)場(chǎng)景中的節(jié)點(diǎn)的引用
enum/constant 枚舉、常亮定義部分(無(wú)實(shí)際代碼)
variable 普通變量定義部分(公開(kāi)的、私有的)
functions 最后是方法函數(shù)定義部分(公開(kāi)的、私有的)
關(guān)于函數(shù)部分也要注意一些小細(xì)節(jié), GDScript 腳本中有公開(kāi)方法和私有方法,這些方法的位置可以隨意,只要自己看著舒服就可以啦。其中幾個(gè)關(guān)鍵地方我簡(jiǎn)單解釋下:
self.set_process(false) 這個(gè)方法能暫?;蜷_(kāi)啟 _process(delta) 方法的運(yùn)行,部分類(lèi)似暫停游戲
self.emit_signal('power_collected', buffer) 發(fā)射信號(hào)的方法,已經(jīng)討論過(guò)了,不過(guò)這里額外添加了一個(gè)參數(shù)
_audioPlayer.stream = xxx 玩家場(chǎng)景中只使用一個(gè)音頻節(jié)點(diǎn),通過(guò)設(shè)置不同的 stream 音頻流可以播放不同的音效
其他部分請(qǐng)參考注釋吧。
- Coin.gd
extends Area2D
玩家名字,根據(jù)玩家名字判斷金幣否被收集
export var playerName = 'Player'
障礙物名字,如果金幣與障礙物重疊則重新生成
export var obstacleName = 'Cactus'
onready var _collisionShape = $CollisionShape2D
func _on_Coin_area_entered(area):
# 判斷碰撞體是否為玩家
if area.name == playerName && area.has_method('collectCoin'):
_collisionShape.disabled = true
area.collectCoin()
self.queue_free()
# 如果是障礙物則刪除該金幣
elif area.name == obstacleName:
self.queue_free()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
金幣節(jié)點(diǎn)非常簡(jiǎn)單,代碼也很簡(jiǎn)潔,主要功能是:玩家收集后自動(dòng)消失,同時(shí)調(diào)用玩家的收集函數(shù) collectCoin() 。為防止調(diào)用出錯(cuò),我在代碼中對(duì)玩家是否有該方法做了判斷。
- Cactus.gd
extends Area2D
export var playerName = 'Player'
func _on_Cactus_area_entered(area):
# 與玩家相撞,調(diào)用玩家的hurt方法
if area.name == playerName && area.has_method('hurt'):
area.hurt()
1
2
3
4
5
6
7
8
這是最簡(jiǎn)單的子場(chǎng)景了!游戲規(guī)則就是:玩家碰到障礙物(仙人掌)后,玩家收到傷害,游戲結(jié)束。邏輯代碼可以參考 Player 場(chǎng)景的 hurt() 方法。
- Power.gd
extends Area2D
export var playerName = 'Player'
export var power = 2 # 能量蘊(yùn)藏的時(shí)間參數(shù)
onready var _collisionShape = Sprite
onready var _timer = DisappearTween
使用Tween節(jié)點(diǎn)實(shí)現(xiàn)放大到消失的動(dòng)畫(huà)
func _startTween():
_tween.interpolate_property(_sprite, 'modulate', Color(1.0, 1.0, 1.0, 1.0), Color(1.0, 1.0, 1.0, 0.0), 0.25, Tween.TRANS_CUBIC, Tween.EASE_IN_OUT)
_tween.interpolate_property(_sprite, 'scale', _sprite.scale, _sprite.scale * 4, 0.25, Tween.TRANS_CUBIC, Tween.EASE_IN_OUT)
_tween.start()
func _on_Power_area_entered(area):
# 玩家收集到能量
if area.name == playerName && area.has_method('collectPower'):
_collisionShape.disabled = true
area.collectPower(power)
_timer.stop()
_startTween()
一定時(shí)間后能量幣消失
func _on_LiftTimer_timeout():
self.queue_free()
動(dòng)畫(huà)結(jié)束后消失
func _on_Tween_tween_completed(object, key):
self.queue_free()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
和金幣、障礙物一樣,也是一個(gè)很簡(jiǎn)單的子場(chǎng)景,不過(guò)我們使用了 Tween 節(jié)點(diǎn),利用代碼實(shí)現(xiàn)能量幣的消失動(dòng)畫(huà)。關(guān)于 Tween 節(jié)點(diǎn)可以參考上一篇文章,對(duì)于方法中每個(gè)參數(shù)的定義可以直接查閱官方 API 文檔。