之前,我们写了一个Connection的子类Player,简单的实现了deal_data方法去处理客户端发送过来的数据(也就是print了一下)。那么,这一章我们就真正的来设计一套简单的客户端和服务端数据交互的逻辑,供我们的《间隙之间》使用。
在网络游戏中,客户端会有很多的操作,比如账号登录、移动角色、攻击、聊天等等。这些操作都需要与服务端进行网络通信。所以我们得设计一套规范的协议用来处理各种操作。
目前,我只打算做登录和移动两个功能。那么我们只需要设计这两个功能的相关协议就行了。
首先,我们人为规定:
1.所有数据传输都用json格式(如果你不喜欢json,那么你完全可以用其他的数据格式)
2.json中必须要有protocol字段,该字段表示协议名称(我们可以通过protocol的值区分这个数据包是登录、攻击还是移动等等)
3.因为粘包,所以我们使用特殊字符串"|#|"进行数据包切割(也就是在每个json字符串后面拼接一个|#|,你也可以用你喜欢的字符串,但是一定要注意数据包内不允许出现该字符串)
什么是粘包:
在python的socket通信中,socket.recv()是接收数据,socket.send()是发送数据。
假如服务端发送:
server_socket.send('这是第一句话')
server_socket.send('这是第二句话')
按理说,客户端需要调用两次recv方法,把这两句话依次接收:
a=client_socket.recv()
b=client_socket.recv()
按照我们所期望的,a='这是第一句话',b='这是第二句话'
但是实际上可能并不是这样!
实际上可能是a='这是第一句话这是第二句话',在第一次recv的时候,就把两个数据包全接收了。
我们把这种现象称之为粘包,出现粘包的原因,大家可以百度了解一下。
所以,我们才需要特殊字符串,把两个或多个粘在一起的数据包给分割开来。
说完上面三点,我们开始设计具体的协议了。
登录协议:
客户端发送:
{"protocol":"cli_login","username":"玩家账号","password":"玩家密码"}|#|
服务端返回:
登录成功:
{
"protocol":"ser_login",
"result":true,
"player_data":{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"昵称","x":5,"y":5}
}|#|
登录失败:
{"protocol":"ser_login","result":false,"msg":"账号或密码错误"}|#|
登录成功时,服务端需要返回玩家的昵称、坐标和uuid(这个是玩家唯一标识,以后会讲到它的用处)这些必要信息。
客户端拿到这些数据后,就可以在地图上正确的显示角色了(这里还少了一个角色id,不然不知道用哪个角色图片,以后会加上的)。
当前所有在线玩家协议:
当玩家登录成功,进入游戏后,在地图上不仅仅要显示自己,还需要显示其他玩家。所以服务端需要告诉客户端,当前有多少玩家在地图上,他们都叫什么名字,在什么位置。
服务端主动发送给客户端:
{
"protocol":"ser_player_list",
"player_list":[
{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"玩家1","x":5,"y":5},
{"uuid":"12343feb0bb041d4b14f4f61379fbbfa","nickname":"玩家2","x":5,"y":5},
]
}|#|
玩家上线协议:
假如当前有2个在线玩家A、B,当第3个玩家C登录成功时,A和B是不知道C玩家上线的。所以需要这个协议,告诉其他在线玩家,有新玩家上线了。
服务端发送给其他客户端:
{
"protocol": "ser_online",
"player_data": {
"uuid":"12343feb0bb041d4b14f4f61379fbbfa","nickname":"玩家C","x":5,"y":5
}
}
玩家移动协议:
当某个玩家移动的时候,需要告诉其他玩家,这个玩家移动到哪里去了。
客户端发送给服务端:
{
"protocol": "cli_move",
"x":6,
"y":7
}|#|
服务端再转发给其他客户端:
{
"protocol": "ser_move",
"player_data":{
"uuid":"12343feb0bb041d4b14f4f61379fbbfa",
"nickname":"玩家C",
"x":5,
"y":5
}
}|#|
ok,目前我们所有的协议都已经设计完了,那么现在就用代码来实现它们。
首先,我们新设计一个ProtocolHandler类,专门用于处理各种客户端发送过来的协议。
class ProtocolHandler:
"""
处理客户端返回过来的数据协议
"""
def __call__(self, player, protocol):
protocol_name = protocol['protocol']
if not hasattr(self, protocol_name):
return None
# 调用与协议同名的方法
method = getattr(self, protocol_name)
result = method(player, protocol)
return result
这里实现了__call__方法,这个方法可以让对象能像函数那样被调用,比如
protocol=ProtocolHandler()
protocol(xxx,xxxx) # 这样就会直接执行__call__方法
参数player是Player对象,protocol是客户端传过来的json字符串转换成的python字典
我们根据protocol的名字直接调用ProtocolHandler中同名的方法
接下来我们实现ProtocolHandler的cli_login和cli_move方法,当客户端发送这两个协议过来的时候,会直接调用这两个方法。
cli_login:
class ProtocolHandler:
def __call__(self, player, protocol):
protocol_name = protocol['protocol']
if not hasattr(self, protocol_name):
return None
# 调用与协议同名的方法
method = getattr(self, protocol_name)
result = method(player, protocol)
return result
@staticmethod
def cli_login(player, protocol):
"""
客户端登录请求
"""
# 由于我们还没接入数据库,玩家的信息还无法持久化,所以我们写死几个账号在这里吧
data = [
['admin01', '123456', '玩家昵称1'],
['admin02', '123456', '玩家昵称2'],
['admin03', '123456', '玩家昵称3'],
]
username = protocol.get('username')
password = protocol.get('password')
# 校验帐号密码是否正确
login_state = False
nickname = None
for user_info in data:
if user_info[0] == username and user_info[1] == password:
login_state = True
nickname = user_info[2]
break
# 登录不成功
if not login_state:
player.send({"protocol": "ser_login", "result": False, "msg": "账号或密码错误"})
return
# 登录成功
player.login_state = True
player.game_data = {
'uuid': uuid.uuid4().hex,
'nickname': nickname,
'x': 5, # 初始位置
'y': 5
}
# 发送登录成功协议
player.send({"protocol": "ser_login", "result": True, "player_data": player.game_data})
# 发送上线信息给其他玩家
player.send_without_self({"protocol": "ser_online", "player_data": player.game_data})
player_list = []
for p in player.connections:
if p is not player and p.login_state:
player_list.append(p.game_data)
# 发送当前在线玩家列表(不包括自己)
player.send({"protocol": "ser_player_list", "player_list": player_list})
cli_move:
class ProtocolHandler:
def __call__(self, player, protocol):
protocol_name = protocol['protocol']
if not hasattr(self, protocol_name):
return None
# 调用与协议同名的方法
method = getattr(self, protocol_name)
result = method(player, protocol)
return result
@staticmethod
def cli_login:
"""代码略"""
@staticmethod
def cli_move(player, protocol):
"""
客户端移动请求
"""
# 如果这个玩家没有登录,那么不理会这个数据包
if not player.login_state:
return
# 客户端想要去的位置
player.game_data['x'] = protocol.get('x')
player.game_data['y'] = protocol.get('y')
# 告诉其他玩家当前玩家的位置变化了
player.send_without_self({"protocol": "ser_move", "player_data": player.game_data})
我们的Player类也需要稍作调整:
class Player(Connection):
def __init__(self, *args):
self.login_state = False # 登录状态
self.game_data = None # 玩家游戏中的相关数据
self.protocol_handler = ProtocolHandler() # 协议处理对象
super().__init__(*args)
在Player的构造方法中,login_state是记录这个玩家有没有登录,game_data是玩家游戏中的一些数据。
protocol_handler就是我们上面写的ProtocolHandler对象。
最后调用父类的构造方法。
特别注意的地方是,super().__init__(*args)一定要放在最后调用,因为父类Connection构造的时候会创建处理socket数据的线程,在Player没有初始化完成时,父类创建的线程可能无法访问子类的一些属性。
Player的deal_data方法也需要重写:
class Player(Connection):
def __init__(self, *args):
"""代码略"""
def deal_data(self, bytes):
"""
我们规定协议类型:
1.每个数据包都以json字符串格式传输
2.json中必须要有protocol字段,该字段表示协议名称
3.因为会出现粘包现象,所以我们使用特殊字符串"|#|"进行数据包切割。这样的话,一定要注意数据包内不允许出现该字符。
例如我们需要的协议:
登录协议:
客服端发送:{"protocol":"cli_login","username":"玩家账号","password":"玩家密码"}|#|
服务端返回:
登录成功:
{"protocol":"ser_login","result":true,"player_data":{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"昵称","x":5,"y":5}}|#|
登录失败:
{"protocol":"ser_login","result":false,"msg":"账号或密码错误"}|#|
当前所有在线玩家:
服务端发送:{"protocol":"ser_player_list","player_list":[{"nickname":"昵称","x":5,"y":5}]}|#|
玩家移动协议:
客户端发送:{"protocol":"cli_move","x":100,"y":100}|#|
服务端发送给所有客户端:{"protocol":"ser_move","player_data":{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"昵称","x":5,"y":5}}|#|
玩家上线协议:
服务端发送给所有客户端:{"protocol":"ser_online","player_data":{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"昵称","x":5,"y":5}}|#|
玩家下线协议:
服务端发送给所有客户端:{"protocol":"ser_offline","player_data":{"uuid":"07103feb0bb041d4b14f4f61379fbbfa","nickname":"昵称","x":5,"y":5}}|#|
"""
# 将字节流转成字符串
pck = bytes.decode()
# 切割数据包
pck = pck.split('|#|')
# 处理每一个协议,最后一个是空字符串,不用处理它
for str_protocol in pck[:-1]:
protocol = json.loads(str_protocol)
# 根据协议中的protocol字段,直接调用相应的函数处理
self.protocol_handler(self, protocol)
最后给Player新增发送数据的方法,具体用法请看代码注释:
class Player(Connection):
def __init__(self, *args):
"""代码略"""
def deal_data(self, bytes):
"""代码略"""
def send(self, py_obj):
"""
给玩家发送协议包
py_obj:python的字典或者list
"""
self.socket.sendall((json.dumps(py_obj, ensure_ascii=False) + '|#|').encode())
def send_all_player(self, py_obj):
"""
把这个数据包发送给所有在线玩家,包括自己
"""
for player in self.connections:
if player.login_state:
player.send(py_obj)
def send_without_self(self, py_obj):
"""
发送给除了自己的所有在线玩家
"""
for player in self.connections:
if player is not self and player.login_state:
player.send(py_obj)
至此,我们网游的服务端基本已经完成了我们写个简单的客户端看看代码是否能正常运行
客户端代码:
import socket
s = socket.socket()
s.connect(('127.0.0.1', 6666)) # 与服务器建立连接
# 发送登录协议,请求登录
s.sendall('{"protocol":"cli_login","username":"admin01","password":"123456"}|#|'.encode())
# 接收服务端返回的消息
data = s.recv(4096)
print(data.decode())
data = s.recv(4096)
print(data.decode())
input("")
s.close()
运行结果:
服务端:
[2019-12-06 14:09:05.932266]服务器启动中,请稍候...
[2019-12-06 14:09:05.933264]服务器启动成功:127.0.0.1:6666
[2019-12-06 14:09:09.063894]有新连接进入,当前连接数:1
客户端:
{"protocol": "ser_login", "result": true, "player_data": {"uuid": "1c68beb59e2a464b9435476ef56f82a3", "nickname": "玩家昵称1", "x": 5, "y": 5}}|#|
{"protocol": "ser_player_list", "player_list": []}|#|
从下一章开始,我们就开始开发客户端了,把这些网络交互的数据用游戏表现出来。