1.回顧
之前,我們寫了一個Connection的子類Player,簡單的實現了deal_data方法去處理客戶端發送過來的數據(也就是print了一下)。那麼,這一章我們就真正的來設計一套簡單的客戶端和服務端數據交互的邏輯,供我們的《間隙之間》使用。
2.設計通信協議
在網絡遊戲中,客戶端會有很多的操作,比如賬號登錄、移動角色、攻擊、聊天等等。這些操作都需要與服務端進行網絡通信。所以我們得設計一套規範的協議用來處理各種操作。
目前,我只打算做登錄和移動兩個功能。那麼我們只需要設計這兩個功能的相關協議就行了。
首先,我們人爲規定:
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,目前我們所有的協議都已經設計完了,那麼現在就用代碼來實現它們。
3.代碼實現
首先,我們新設計一個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)
至此,我們網遊的服務端基本已經完成了我們寫個簡單的客戶端看看代碼是否能正常運行
4.運行
客戶端代碼:
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": []}|#|
從下一章開始,我們就開始開發客戶端了,把這些網絡交互的數據用遊戲表現出來。