在这个小时内,我们将创建我们的最终游戏。 它是Nintendo Entertainment System时代传奇游戏Bomberman的克隆版本,但具有一个原作在当时没有的杀手锏:网络多人游戏。游戏由多名玩家在一个自上而下的迷宫中进化,可以通过安放炸弹来打破墙壁并杀死其他玩,增加自己的分数。这款游戏非常经典,在我们从游戏1和2中吸取了教训之后,制作一个单人版的游戏应该不会太麻烦,所以可以让我们把更多的精力放在网络方面。
每个玩家都由自己的游戏实例控制。理想情况下,每个实例都是在自己的电脑上,有一个真正的人类玩家。但如果你缺乏多余的电脑和/或与你分享Godot乐趣的朋友,完全可以在自己的电脑上多次启动游戏,并从一个窗口切换到另一个窗口,依次控制每个玩家。
前面说过,玩家在以瓷砖为基础的迷宫中进化,所以可以用方向键进行垂直和水平移动,用空格键埋下炸弹。一旦埋下炸弹,炸弹会等待两秒后才会爆炸。鉴于这是一款基于瓷砖的游戏,炸弹会按照垂直和水平的瓷砖来引爆(它不会在对角线瓷砖中传播)。
爆炸会一直传播,直到到达两块瓷砖或撞到坚硬的墙壁。另一方面,较软的墙壁会被爆炸摧毁,玩家也会死亡。
当玩家被杀死时,玩家会返回到开始的位置,杀死他的人会多得100分(除非玩家自杀,因此得到−50分)
FIGURE 23.1
最终游戏的截图。
鉴于这是一款多人游戏,我们需要建立一个主菜单来选择是以服务器的身份开始游戏还是以客户端的身份加入现有的游戏。最后,我们需要一个大厅,玩家加入后就在其中等待,直到服务器决定开始游戏的时间。
你知道该怎么做:创建一个新的项目,然后添加经典文件夹(场景、脚本等),就能拥有一个组织良好的项目。
FIGURE 23.2
我们的项目目录。
在开始跳转代码之前,你应该先把Hour23文件夹中的字体和Sprite资源复制过来。当然,在阅读本教程时,如果你遇到困难,不要犹豫,可以看看脚本,或者用Godot编辑器打开这个项目。
按照Godot的惯例,我们首先将游戏划分为小场景,让我们打破复杂性,提高模块化。
TABLE 23.1 游戏所需的场景
Scene | Description |
---|---|
Menu and Lobby | 鉴于菜单和大厅场景的简单性,我们可以将它们组合为一个场景,然后选择要显示的场景(一旦游戏开始就不显示)。此场景包含一个Control 节点(这是UI的基本节点)和两个Control 子级(一个用于菜单,一个用于大厅),每个子级由Label ,Button 和Input 。 |
Arena | 这是我们的主要场景,其中实例化的玩家场景在不断演变,炸弹和爆炸场景也在其中。 我们还必须在这里处理迷宫,这是TileMap节点的完美用例,也是具有Position2D节点的玩家的起始位置。 最后,该场景负责通过简单的标签显示每个玩家的得分。 |
Player | 玩家场景由KinematicBody2D和CollisionShape2D组成,这允许我们轻松地移动它并处理它的碰撞。我们还需要一个Sprite和一个AnimationPlayer来在屏幕上显示player,并在它移动时使它动起来。最后,计时器节点是用来避免玩家滥发炸弹。 |
Bomb | 考虑到炸弹放置后不会移动,我们可以用CollisionShape2D来表示它。就像对于玩家一样,我们使用Sprite和AnimationPlayer节点来帮助玩家发现即将爆炸的炸弹。说到这里,计时器允许我们配置爆炸发生的时间。 |
Explosion | 因为爆炸是通过跟随瓷砖传播的,我们可以依靠竞技场的瓷砖图来处理碰撞。这意味着我们的爆炸场景必须使用一个Sprite和一个AnimationPlayer来显示每个瓷砖的爆炸动画 |
正如我们在Hour 21中所看到的,Godot高级网络系统的一大优势是,它允许我们首先专注于开发一款单人游戏,然后通过声明节点应该如何同步在一起,为其添加多人游戏能力。按照这个思路,我们先做一个单人游戏《炸弹人》,然后再把重点转向做多人游戏。
我们来看看如何制作场景。
从最重要的场景开始:玩家。往往是游戏中最复杂的场景之一。然而,我们的游戏还很小,所以没有理由惊慌。Hour 9告诉我们,对于一款2D平台游戏来说,KinematicBody2D是玩家控制场景的最佳根节点,因为它不受物理学的影响,同时它还能让我们检测到碰撞,并在需要时进行相应的移动。
正如你已经知道的,KinematicBody2D需要一个碰撞形状来工作,所以我们将添加一个CollisionShape2D并配置它的Shape属性。考虑到我们的玩家会在瓷砖上移动,我们可能会想使用一个瓷砖大小的矩形形状。然而,这将导致连续的碰撞,最终往往会导致我们的节点被卡住。所以,使用比瓷砖大小稍小的圆形碰撞形状是一个更好的解决方案。
说到这里,我们应该选择32像素的瓷砖大小,所以我们将所有的Sprites和碰撞形状都缩放到这个大小。
我们还要给根节点添加一个子节点:Timer节点来控制玩家的射击速度。我们将它的等待时间设置为一秒,鉴于这个计时器是在放置炸弹时由代码触发的,所以选择One Shot属性,并确保Autostart被禁用。
NOTE
碰撞形状和缩放
第9小时的提醒:缩放碰撞形状时要小心。确保它们是一致的并且具有非负值。否则,你的物理会发生奇怪的事情。
是时候让玩家可见了。我们可以使用简单的AnimatedSprite来完成这个任务,但是为了增加一些变化,这次我们将选择更强大的Sprite和AnimationPlayer组合。我们已经不在堪萨斯了。
首先,将Sprite节点添加为根的子节点,然后使用Texture属性对其进行配置。 点击加载并选择“ sprites / player1.png”。 在这一点上,您应该意识到我们加载的图像不是单个Sprite,而是一个Spritesheet(一个包含一个相邻Sprite的图像,多个Sprite),因此请使用Animation:Vframes和Animation:Hframes属性(在 在我们的例子中,spritesheet包含一行和三列,因此Vframes = 1,Hframes = 3)。
现在,我们可以使用Frame属性来遍历动画并选择默认的动画。 如您所见,我们的Spritesheet由三个Sprite组成:一个空闲姿势和两个组成原始行走动画的步骤(图23.3)。
FIGURE 23.3
在player节点上进行Sprite配置。
当你可以使用像AnimationPlayer这样的工具时,手工使用Frame属性(或者甚至用GDNative脚本更新它)是不可能的。添加这个节点(确保它是根的直接子节点)并开始配置它的动画。
点击AnimationPlayer,动画菜单将在编辑器的下方打开。点击创建一个新的动画并将其称为idle。现在使用场景树查看器选择sprite节点。在检查器中,找到Animation:Frame属性并确保它的值为0。现在点击,一个弹出窗口询问你是否要添加一个新的轨道到你的动画。点击“创建”。
FIGURE 23.4
玩家的闲置动画。
你已经创建了第一个由…一帧组成的动画。这样说似乎很傻,但这意味着你现在可以轻松地改进一帧空闲动画。一旦触发了行走动画,你就需要触发这个简单的空闲动画,否则玩家将永远行走。
说到行走动画,它和闲置动画是一样的,只不过你要点击两次Animation:Frame属性旁边的按钮(每帧一次)。这个时候,要注意配置动画的Length和Step属性,否则,你最终会得到一些笨拙的东西(通常第1帧在0秒,第2帧在0.1秒,然后第1帧又在0.9秒后,当动画循环的给定默认Length为1秒时)。另外,别忘了将轨道设置为Discrete,否则,默认的连续模式会将你的两帧融合在一起,你最终会得到一个连续播放的单帧(图23.5)。
FIGURE 23.5
玩家的行走动画。
最后,你应该有第三个也是最后一个动画来做重生。做同样的步骤,但这次不要改变Sprite,但要让它闪烁(这将帮助玩家发现他的角色已经重生的地方)。要做到这一点,使用Visibility:Visible属性旁边的。注意,这也是我们使用AnimatedSprite做动画的不足之处(图23.6)。
FIGURE 23.6
玩家的重生动画。
创建炸弹场景
炸弹的场景和玩家场景很相似。
1.创建一个StaticBody2D节点作为根节点,并重命名为 “炸弹”。
2.添加一个子CollisionShape2D并激活其Disabled属性。这个想法是,玩家在当前所站的瓷砖上放置炸弹,所以你要等一下再启用炸弹物理,让玩家离开瓷砖,避免出现小故障。
3.添加一个定时器,重命名为 “EnableCollisionTimer”,并配置为等待时间=0.5,一次性=True,自动启动=True。
4、创建一个sprite,并将Texture设置为 “sprite/bomb.png”。和玩家一样,它是一个3×1的spriteheet,使炸弹越接近爆炸越红。
5.最后,创建并使用AnimationPlayer,配置一个默认的持续2秒的动画,并在0、1、1.5秒时改变炸弹的颜色。激活Playback:Active复选框,在节点被创建时启动动画。
请注意,我们不需要创建一个Timer来控制炸弹何时爆炸:我们将通过连接到AnimationPlayer提供的animation_finished事件来获得类似的结果。
.
我们先用一个Node2D作为场景的根节点(为了清楚起见,别忘了把它改名为 “Arena”)。严格来说,这个节点并没有什么用处,但它可以让你更好地划分子节点。
为了构建迷宫,你可以为每一种类型的块创建一个场景,然后手动将它们实例化并放置在竞技场场景中。然而,这是一个非常繁琐的任务,我们有一个更好的工具来实现这个任务:TileMap节点。顾名思义,这种类型的节点可以让你轻松地在场景中选择和放置瓷砖。
但是在你使用TileMap之前,你需要建立一个Tileset,定义不同的瓷砖来放置。对于我们的游戏,我们需要三种类型的瓷砖。
BackgroundBrick:代表玩家将行走的地面。
SolidBrick:代表竞技场的墙壁,玩家无法越过它们,爆炸也会被阻止。
BreakableBrick:像SolidBrick一样阻挡玩家,玩家一旦被爆炸击中就会被摧毁
创建一个新的场景,将其称为 “scene/tileset-source.tscn”,并添加一个根节点Node2D。现在,我们要添加Sprite子节点,每个节点代表一个瓷砖。确保给这些节点都起一个有意义的名字,因为一旦导入到TileMap中,瓷砖将保留它们。对于每个Sprite,在Texture属性中加载 “sprite/bricks.png”(再次强调,这是一个3×1的spriteheet,所以要相应地修正Vframes和Hframes),并配置Frame属性,使每个瓷砖都不同。
在此基础上,在SolidBrick和BreakableBrick上添加一个StaticBody2D与CollisionShape2D(其RectangularShape2D为32×32)的子代(图23.7)。
FIGURE 23.7
TileSet source scene
现在打开菜单Scene –> Convert To … –> TileSet,并将 TileSet保存为 “scenes/tileset.res.”。
回到竞技场场景。添加一个TileMap子节点,配置Tileset属性以加载闪亮的新瓷砖集,并开始用瓷砖绘制地图(图23.8)。
FIGURE 23.8
Drawing the tilemap
一旦你对地图的结果感到满意,继续为玩家添加出生位置。添加一个Node2D,并将其命名为 “SpawnPositions”。在它的内部,添加四个Position2D,你想让你的玩家从那里开始。当然,它们应该放在BackgroundBrick上,因为你不希望玩家卡在墙上)。
现在,创建另一个Node2D,并将其命名为 “Players”,这是一个节点,你将在这里注册所有的玩家场景,以方便地迭代它们(更多信息在脚本部分)。
最后,创建一个简单的Label节点,命名为 “ScoresBoard”,显示每个玩家的分数。
FIGURE 23.9
玩家、炸弹、爆炸、竞技场的场景树
NOTE
使绘制顺序正确
即使是在2D中,Z轴也是有用的,它可以决定在什么上面显示什么。在这里,我们希望竞技场场景的z值保持在默认值0,然后将炸弹、玩家和爆炸的z值设置得更高,按照这个顺序。
要控制player,首先将一个脚本附加到player场景根节点上,保存为 “scripts/player.gd”,然后开始调整它(见清单23.1)。
LISTING 23.1 Player Movements with Kinematic Body — player.gd
const WALK_SPEED = 200
var dead = false
var direction = Vector2()
var current_animation = "idle"
onready var animation:AnimationPlayer=$AnimationPlayer
func _physics_process(delta):
if dead:
return
if (Input.is_action_pressed("ui_up")):
direction.y = - WALK_SPEED
elif (Input.is_action_pressed("ui_down")):
direction.y = WALK_SPEED
else:
direction.y = 0
if (Input.is_action_pressed("ui_left")):
direction.x = - WALK_SPEED
elif (Input.is_action_pressed("ui_right")):
direction.x = WALK_SPEED
else:
direction.x = 0
move_and_slide(direction)
rotation = atan2(direction.y, direction.x)
var new_animation = "idle"
if direction:
new_animation = "walking"
if new_animation != current_animation:
animation.play(new_animation)
current_animation = new_animation
现在应该不奇怪了:创建一个_physics_process()函数来控制玩家的移动。使用KinematicBody2D提供的move_and_slide()函数,它可以自动帮你处理玩家的移动和处理碰撞。如果需要的话,更新动画(因为在每一帧重新设置动画会使它只播放第一帧的动画)。
继续创建一个_process函数,允许玩家放置炸弹(清单23.2)。
LISTING 23.2 Player Planting Boms
var can_drop_bomb = true
var tilemap = get_node("/root/Arena/TileMap")
func _process(delta):
if dead:
return
if Input.is_action_just_pressed("ui_select") and can_drop_bomb:
dropbomb(tilemap.centered_world_pos(position))
can_drop_bomb = false
drop_bomb_cooldown.start()
sync func dropbomb(pos):
var bomb = bomb_scene.instance()
bomb.position = pos
bomb.owner = self
get_node("/root/Arena").add_child(bomb)
func _on_DropBombCooldown_timeout():
can_drop_bomb = true
就像它的名字一样,将_on_DropBombCooldown_timeout()连接到玩家的DropBombCooldown定时器。
首先创建 “scripts/bomb.gd”,并将其连接到爆炸场景(见清单23.3)。这个脚本做了三件事:首先,它在计时器完成后启用CollisionShape2D,然后根据TileMap上出现的瓷砖类型,等待AnimationPlayer结束生成爆炸场景实例,最后,但并非最不重要的是,它通过销毁自身来完成(因为它爆炸了,还记得吗?)。
LISTING 23.3 Bomb Explosion Expanding Algorithm
extends StaticBody2D
const EXPLOSION_RADIUS=2
onready var explosion_scene =preload("res://Scenes/Explosion.tscn")
onready var tilemap:TileMap=get_node("/root/Arena/TileMap") as TileMap
onready var tile_solid_id=tilemap.tile_set.find_tile_by_name("SolidBrick")
func propagate_explosion(centerpos,propagation):
var border_explosion=null
var center_tile_pos=tilemap.world_to_map(centerpos)
var explosions=[]
for i in range(1,EXPLOSION_RADIUS + 1):
var tilepos = center_tile_pos + propagation * i
if tilemap.get_cellv(tilepos) != tile_solid_id:
var explosion = explosion_scene.instance()
explosion.position=tilemap.centered_world_pos_from_tilepos(tilepos)
explosion.direction= propagation
explosion.type=explosion.SIDE
border_explosion=explosion
explosions.append(explosion)
else:
break
if border_explosion:
border_explosion.type = border_explosion.SIDE_BORDER
for explosion in explosions:
get_parent().add_child(explosion)
func _on_AnimationPlayer_animation_finished(anim_name):
var center_explosion=explosion_scene.instance()
center_explosion.position = position
center_explosion.type=center_explosion.CENTER
get_parent().add_child(center_explosion)
propagate_explosion(position,Vector2(0,1))
propagate_explosion(position,Vector2(0,-1))
propagate_explosion(position,Vector2(1,0))
propagate_explosion(position,Vector2(-1,0))
queue_free()
这里重要的一点是如何使用TileMap的map_to_world()、get_cellv()和find_tile_by_name()来检索瓷砖来实例化一个爆炸场景。
说到爆炸,是时候创建并附上它的脚本 "scripts/explosion.gd "了。其思路与bomb.gd中大致相同:使用TileMap检索爆炸发生的瓷砖,确保这个瓷砖是BackgroundTile。然后检索玩家的节点(现在我们很高兴有了这个“Arena/Players”节点),并检查每个节点是否站在爆炸的瓷砖上(清单23.4)。
LISTING 23.4 Explosions
extends Sprite
onready var tilemap:TileMap =get_node("/root/Arena/TileMap") as TileMap
onready var animation:AnimationPlayer =get_node("AnimationPlayer") as AnimationPlayer
var direction=null
enum {CENTER,SIDE,SIDE_BORDER}
var type = CENTER
func _ready():
if type == CENTER:
animation.play("explosion_center")
if type == SIDE:
animation.play("explosion_side")
if type == SIDE_BORDER:
animation.play("explosion_side_border")
if direction:
rotation = atan2(direction.y,direction.x)
var tile_pos =tilemap.world_to_map(position)
var tile_background_id=tilemap.tile_set.find_tile_by_name("BackgroundBrick")
tilemap.set_cellv(tile_pos,tile_background_id)
for player in get_tree().get_nodes_in_group("players"):
if player:
# print_debug(player)
var playerpos = tilemap.world_to_map(player.position)
if playerpos == tile_pos:
player.damage()
func _on_AnimationPlayer_animation_finished(anim_name):
queue_free()
就像炸弹一样,我们也应该添加一个连接到AnimationPlayer的动画结尾的函数,一旦不再需要爆炸节点,就可以释放它。
只要稍加打磨,你的炸弹人游戏就可以玩了。
1.在竞技场/玩家节点中添加一个玩家场景。
2.将竞技场场景配置为Godot项目的主场景。
3.点击运行,开始玩你的炸弹人。
4.现在你可以在场景中添加第二个玩家,并修正player.gd脚本,以根据当前处理的玩家节点处理不同的键。现在你可以进行真正的决斗了。
正如你在前面所看到的,如果你的游戏是多人游戏,你不能直接在竞技场场景中开始游戏:你必须让玩家首先选择他是想主持一场游戏还是加入一场游戏。所以,你应该创建大厅场景,它将主菜单与列出用户的大厅菜单累加在一起。这主要是GUI小部件的放置和信号连接,和多人游戏没有太大关系,所以我们把它留给你(把“scenes/lobby.tscn”和 “scripts/lobby.gd”从 "23小时 "文件夹中复制/粘贴即可)。
FIGURE 23.11
gamestate.gd的AutoLoad配置。
除此之外,在竞技场场景创建之前,我们应该保留玩家的信息(比如昵称)(所以我们不能把这些信息存储在 “Arena/Players/”下)。
一个解决方案是将大厅设置为主场景,然后将玩家的信息存储在连接到这个场景根节点的脚本中。这是可行的;然而,更优雅的做法是将这些信息存储在自动加载中,并让它们在所有场景中可用,即使你决定删除大厅场景(例如,如果你将游戏作为专用服务器启动)。
创建一个新的 “scripts/gamestate.gd”,然后选择 “项目”>“项目设置”>“自动加载”,加载新创建的脚本,并确保Singleton被设置为Enable(图23.12)。
FIGURE 23.12
gamestate.gd的AutoLoad配置
现在,打开gamestate.gd脚本,复制清单23.1中的host_game()和join_game()函数。当用户点击host或join按钮时,这些函数就会被调用,同时隐藏主菜单以显示等待游戏开始的玩家列表。
正如你在第22小时中所看到的,一个玩家加入或离开游戏会触发一个信号,这个信号连接到一个函数,以保持玩家列表的更新(清单23.5)。
LISTING 23.5 Gamestate网络信号处理
var players = {}
func _ready():
get_tree().connect("network_peer_connected", self, "_player_connected")
get_tree().connect("network_peer_disconnected", self,"_player_disconnected")
get_tree().connect("connected_to_server", self, "_connected_ok")
get_tree().connect("connection_failed", self, "_connected_fail")
get_tree().connect("server_disconnected", self, "_server_disconnected")
func _player_disconnected(id):
# 当一个对等点消失时,每个对等点都会得到这个通知,
# 因此我们删除了相应的玩家数据.
var player_node = get_node("/root/Arena/Players/%s" % id)
if player_node:
# 如果我们已经开始游戏,那么玩家节点将不存在
player_node.queue_free()
players.erase(id)
func _connected_ok():
# 此方法仅从新连接的客户端调用。因此,我们将自己注册到服务器.
var player_id = get_tree().get_network_unique_id()
# 注意,对于这个调用
rpc("register_player_to_server", player_id, player_nickname)
# 现在只需等待服务器启动游戏
emit_signal("waiting_for_players")
func _connected_fail():
_stop_game("Cannot connect to server")
func _server_disconnected():
_stop_game("Server connection lost")
func _stop_game(msg):
#终止网络功能
get_tree().network_peer=null
#删除竞技场场景,清空players字典
players.clear()
if game_started:
get_node("/root/Arena").queue_free()
game_started=false
emit_signal("game_ended",msg)
在这里,_stop_game()函数禁用网络并切换回主菜单,但这取决于你。
更有趣的是 register_player_to_server() 函数的 RPC 调用。这个想法是让加入的客户端调用服务器来传达他的昵称。作为回报,服务器会告诉他是否可以加入游戏(如果游戏还没有开始,如果玩家少于4人)。反过来,服务器用RPC调用客户端,通知他们新加入的客户端,反之亦然(清单23.6)。
LISTING 23.6 Gamestate注册新玩家
master func register_player_to_server(id, name):
# 作为服务器,我们在这里通知是否允许新客户端加入游戏
if game_started:
rpc_id(id, "_kicked_by_server", "Game already started")
elif len(players) == MAX_PLAYERS:
rpc_id(id, "_kicked_by_server", "Server is full")
# 将已经存在的玩家发送给新加入的
for p_id in players:
rpc_id(id, "register_player", p_id, players[p_id])
# Now register the newcomer everywhere, note the newcomer's peer will also be called
rpc("register_player", id, name)
# register_player is slave, so rpc won't call it on our peer (of course we could have set it sync to avoid this)
register_player(id, name)
slave func register_player(id, name):
players[id] = name
最终,服务器上的用户会失去耐心,点击开始游戏按钮。这就会在start_game()过程中向所有人(包括他自己)发送一个RPC(清单23.7)。
LISTING 23.7 Gamestate开始游戏
sync func start_game():
# Load the main game scene
var arena = load("res://scenes/arena.tscn").instance()
get_tree().get_root().add_child(arena)
var spawn_positions = arena.get_node("SpawnPositions").get_children()
# Populate each player
var i = 0
for p_id in players:
var player_node = player_scene.instance()
player_node.set_name(str(p_id)) # Useful to retrieve the player node wit
a node path
player_node.position = spawn_positions[i].position
...
player_node.set_network_master(p_id)
arena.get_node("Players").add_child(player_node)
i += 1
...
emit_signal("game_started")
在这里,我们创建竞技场场景,并为每个连接的peer添加一个玩家场景实例。注意要给每个玩家场景实例配置不同的master ,对应其对等体,否则,只有服务器能玩。
如果我们现在尝试在多人游戏中运行游戏,我们可以启动一个服务器,连接客户机,甚至在每个连接的对等体上创建竞技场场景,所有的在线玩家都在上面。
但是,当我们开始向游戏发送输入时,一个残酷的事实就来了:玩家、炸弹和爆炸场景中的对等体之间没有同步。现在是解决这个问题的时候了。
关于 player场景,我们已经将它们配置为由各自的对等体拥有。这意味着我们可以很容易地使用is_network_master()调用来运行只在master对等体上负责处理输入的代码(清单23.8)。
LISTING 23.8 Player Controlled Only by its Master Peer
func _physics_process(delta):
if not is_network_master():
return
if not dead:
if (Input.is_action_pressed("ui_up")):
...
rpc('_dropbomb', tilemap.centered_world_pos(position))
...
func _process(delta):
if not is_network_master() or dead:
return
if Input.is_action_just_pressed("ui_select") and can_drop_bomb:
...
move_and_slide(direction)
_update_rot_and_animation(direction)
# Send to other peers our player info
# Note we use unreliable mode given we synchronize during every frame
# so losing a packet is not an issue
rpc_unreliable("_update_pos", position, direction)
sync func _dropbomb(pos):
var bomb = bomb_scene.instance()
bomb.position = pos
bomb.owner = self
get_node("/root/Arena").add_child(bomb)
slave func _update_pos(new_pos, new_direction):
position = new_pos
direction = new_direction
_update_rot_and_animation(direction)
现在的诀窍是将负责处理输入的代码与实际更新节点状态的代码分开(这就是我们为dropbomb()
所做的事情)。请注意,有时创建一个专门用于slave同步的函数会更容易:在这里,我们直接处理master的位置和方向属性,然后在slaves上使用_update_pos()
。这样做有两个好处:首先,它避免了在调用RPC之前创建临时值来存储新的方向和位置,然后它允许我们使用rpc_unreliable
来调用slaves(调用是在每一帧上完成的,所以我们不在乎是否会时不时地失去同步),因为master总是保持这些属性的真实值。
你可能会问,那炸弹和爆炸的场景呢?好吧,首先告诉大家一个好消息:我们的炸弹场景在各个对等体中是完全确定的,所以我们不用为它做任何事情。关于爆炸场景,鉴于它是由炸弹创建的,你可以确信它将在正确的地方产生,在正确的时间爆炸。诀窍是,考虑到玩家的同步方式是不可靠的RPC,你不应该在每个对等体上检查与他们的碰撞(否则,一个同伴可能会滞后,当其他人没有被杀死时,一个玩家可能会被视为死亡)。
解决方法就是简单的将爆炸场景的玩家碰撞检查委托给服务器,服务器将RPCplayer.damage()
进行同步(清单23.7)。
LISTING 23.7 Explosion Player Collision Controlled by Master
func _ready():
...
if not is_network_master(): return
# Now that we know which tile is blowing up, retrieve the players
# and destroy them if they are on it
for player in get_tree().get_nodes_in_group('players'):
var playerpos = tilemap.world_to_map(player.position)
if playerpos == tile_pos:
player.rpc("damage", owner.id)
最后,您可以向我们的Player.damage函数添加sync关键字,游戏应该已经完成。 恭喜你!
NOTE
谁是Master,谁是Slave?
记住,如果没有明确配置,主/从属性会继承其父级,默认的根场景主控是服务器。所以在我们的游戏中,即使我们用Players节点创建了各种主控的炸弹,我们也总是把它们添加到竞技场场景中,而竞技场场景是由服务器控制的。最后,服务器才是所有炸弹(和爆炸)场景的主人。
请记住,如果没有明确配置,master/slave属性将继承其父属性,默认的根场景maste是服务器。所以在我们的游戏中,即使我们用不同的masters的玩家节点创建炸弹,我们总是把它们添加到由服务器控制的竞技场场景中。最后,服务器是所有炸弹(和爆炸)场景的主人。
LISTING 23.9 Player Adds Kill
func _process(delta):
if not is_network_master(): # don't do the exploit on our own pla
if Input.is_action_just_pressed("ui_select"):
rpc("damage", id)
...
在这一个小时里,我们又从头开始完成了一个游戏!这一次,我们使用了强大的AnimationPlayer,它可以使用你场景中的任何东西来创建动画,我们还将TileMap与KinematicBody2D玩家混合在一起,重现了NES时代的游戏风格。除此之外,我们还增加了游戏的网络功能和多人游戏,并看到如何修改其场景,以确定性的方式做同步,同时尽量远离作弊者。
Q. 我的客户机无法加入服务器。
A. 确保你在客户端和服务器上都配置了相同的端口。如果两者不在同一台电脑上,你也应该验证IP地址,确保没有防火墙在捣乱。(我们告诉过你网络是一件很复杂的事情吗?)
Q. 我可以先配置客户端网络,再配置服务器网络吗?
A. 你可以以任何顺序配置网络。如果客户端比服务器先准备好,它将轮询它,直到它准备好。但是,请记住,如果服务器需要太长的时间来响应,就会出现超时。
看看你能不能回答下面的问题来测试你的知识。
Quiz
1.用AnimationPlayer可以做什么动画?
2.使用TileMap需要什么?
Answers
1.任何节点的每一个属性,就是这么强大。
2.你需要一个Tileset,它是由一个Sprite组成的场景生成的。你也可以添加碰撞节点和形状来处理物理学。但是,请记住,你不能将脚本分配给这些节点。
我们将游戏的某些部分放在一边,以简化操作。 现在是实施它们的好时机:
1.通过给每个人不同的颜色,增加玩家之间的多样性。
2.处理重生功能:一旦命中,一个玩家应该移动到它的初始位置,然后一个闪烁的动画触发,在此期间,它不能移动。
3.创建计分系统:击中一个玩家获得100分,杀死自己失去50分。记住,每个特性都应该在网络上同步,并且不存在作弊行为。