一.运行效果
二.介绍
源码github:https://github.com/zxf20180725/pygame-online-demo.git
这只是一个简单的联网程序Demo,代码有很多不严谨的地方,仅当抛砖引玉了。
运行客户端程序,会随机取一个名字进入游戏。使用wsad移动头像(蓝葵~)。
三.代码解析
注意,这里只会贴出部分核心代码,完整代码请在上面的github链接中下载。
全局部分的代码,这些都有注释了,具体作用,后面会讲到。foxyball.cn是我的服务器域名,这一年内应该都是有效的。
import random
import sys
import time
from random import randint
from threading import Thread
import pygame
import socket # 导入 socket 模块
from base import Protocol
ADDRESS = ('127.0.0.1', 8712) # ('foxyball.cn', 8712) # 如果服务端在本机,请使用('127.0.0.1', 8712)
WIDTH, HEIGHT = 640, 480 # 窗口大小
g_font = None
g_screen = None # 窗口的surface
g_sur_role = None # 人物的role
g_player = None # 玩家操作的角色
g_other_player = [] # 其他玩家
g_client = socket.socket() # 创建 socket 对象
看一个程序的代码,应该从它的入口开始看。
if __name__ == '__main__':
# 初始化
init_game()
# 游戏循环
main_loop()
入口很简单,就调用了两个函数,那么我们先看看init_game()做了什么。
def init_game():
"""
初始化游戏
"""
global g_screen, g_sur_role, g_player, g_font
# 初始化pygame
pygame.init()
pygame.display.set_caption('网络游戏Demo')
g_screen = pygame.display.set_mode([WIDTH, HEIGHT])
g_sur_role = pygame.image.load("./role.png").convert_alpha() # 人物图片
g_font = pygame.font.SysFont("fangsong", 24)
# 初始化随机种子
random.seed(int(time.time()))
# 创建角色
# 随机生成一个名字
last_name = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫',
'蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张',
'孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', ]
first_name = ['梦琪', '忆柳', '之桃', '慕青', '问兰', '尔岚', '元香', '初夏', '沛菡',
'傲珊', '曼文', '乐菱', '痴珊', '孤风', '雅彤', '宛筠', '飞松', '初瑶',
'夜云', '乐珍']
name = random.choice(last_name) + random.choice(first_name)
print("你的昵称是:", name)
g_player = Role(randint(100, 500), randint(100, 300), name)
# 与服务器建立连接
g_client.connect(ADDRESS)
# 开始接受服务端消息
thead = Thread(target=msg_handler)
thead.setDaemon(True)
thead.start()
# 告诉服务端有新玩家
send_new_role()
从与服务器建立连接开始讲吧(28行)。如果对python的socket不太熟悉的话,可以先看看这两篇文章:,,g_client是一个socket对象,与指定的服务端建立连接。
接收服务端消息部分,我新开了一个线程进行处理(因为recv是阻塞线程的)。处理服务端消息的函数是msg_handler,这个函数稍后再讲,我们继续往下看send_new_role函数。
def send_new_role():
"""
告诉服务端有新玩家加入
"""
# 构建数据包
p = Protocol()
p.add_str("newrole")
p.add_int32(g_player.x)
p.add_int32(g_player.y)
p.add_str(g_player.name)
data = p.get_pck_has_head()
# 发送数据包
g_client.sendall(data)
Protocol是我们自定义的游戏数据包协议,关于Protocol的设计思路都在这篇文章:
这里,我们构造了一个名字叫“newrole”的数据包,并且加入了玩家的信息(坐标和昵称),最后把这个数据包发送给服务端。这个“newrole”的作用就是告诉服务端有一个新玩家加入游戏啦,然后服务端又会告诉其他玩家有个新玩家加入了(这就实现了可以在窗口里看到其他玩家的功能)。我会在下一篇文章详细的讲解服务端的设计,这里就不多说了。
回到我们的程序入口来,接下来就该执行main_loop啦。
def main_loop():
"""
游戏主循环
"""
while True:
# FPS=60
pygame.time.delay(32)
# 逻辑更新
update_logic()
# 视图更新
update_view()
每个游戏都必不可少的游戏主循环。循环里很简单,就调用了3个函数。pygame.time.delay(32)让每次循环间隔32毫秒,也就是说每秒循环执行60次左右。然后就是逻辑更新和视图更新了,这两个函数请尽可能的解耦。
那我们继续看逻辑更新,这个demo的游戏逻辑很简单,就是用wasd控制角色移动。
def update_logic():
"""
逻辑更新
"""
# 事件处理
handler_event()
def handler_event():
# 事件处理
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_w:
g_player.y -= 5
elif event.key == pygame.K_s:
g_player.y += 5
elif event.key == pygame.K_a:
g_player.x -= 5
elif event.key == pygame.K_d:
g_player.x += 5
send_role_move() # 告诉服务器,自己移动了
角色每次移动之后,要把最新的坐标告诉给服务端,服务端再把这个角色的最新坐标发送给其他客户端,这样其他客户端就能看到你在移动了。send_role_move函数就是把当前坐标发送给服务端。
def send_role_move():
"""
发送角色的坐标给服务端
"""
# 构建数据包
p = Protocol()
p.add_str("move")
p.add_int32(g_player.x)
p.add_int32(g_player.y)
data = p.get_pck_has_head()
# 发送数据包
g_client.sendall(data)
回到我们的游戏主循环,继续看视图更新。
def update_view():
"""
视图更新
"""
g_screen.fill((0, 0, 0))
# 画角色
g_screen.blit(g_player.sur_name, (g_player.x, g_player.y - 20))
g_screen.blit(g_sur_role, (g_player.x, g_player.y))
# 画其他角色
for r in g_other_player:
g_screen.blit(r.sur_name, (r.x, r.y - 20))
g_screen.blit(g_sur_role, (r.x, r.y))
# 刷新
pygame.display.flip()
其中,g_other_player是一个存着其他在线玩家的列表。那么这个列表中的内容是从哪里来的呢?内容当然是从服务端发过来的。还记得本文最开始提到的新开一个线程处理服务端消息吗?就是那个msg_handler函数,现在来研究研究它。
def msg_handler():
"""
处理服务端返回的消息
"""
while True:
bytes = g_client.recv(1024)
# 以包长度切割封包
while True:
# 读取包长度
length_pck = int.from_bytes(bytes[:4], byteorder='little')
# 截取封包
pck = bytes[4:4 + length_pck]
# 删除已经读取的字节
bytes = bytes[4 + length_pck:]
# 把封包交给处理函数
pck_handler(pck)
# 如果bytes没数据了,就跳出循环
if len(bytes) == 0:
break
外层的while循环是用来接收服务端的消息,内层的while循环是用来切割数据包的(tcp粘包分包了解一下)。但这里还有个问题,是我在代码里没有去处理的。那就是tcp分包问题,这里内层while循环只解决了粘包。分包的问题,在以后的文章中会讲。内层while循环的逻辑为什么要这么写,大家还是去看看我之前发的那篇文章吧。
数据包切割好了之后,就调用pck_handler函数处理数据包。
def pck_handler(pck):
p = Protocol(pck)
pck_type = p.get_str()
if pck_type == 'playermove': # 玩家移动的数据包
x = p.get_int32()
y = p.get_int32()
name = p.get_str()
for r in g_other_player:
if r.name == name:
r.x = x
r.y = y
break
elif pck_type == 'newplayer': # 新玩家数据包
x = p.get_int32()
y = p.get_int32()
name = p.get_str()
r = Role(x, y, name)
g_other_player.append(r)
elif pck_type == 'logout': # 玩家掉线
name = p.get_str()
for r in g_other_player:
if r.name == name:
g_other_player.remove(r)
break
我们这个小demo一共就设计了三个协议类型,"playermove"、"newplayer"和"logout"。"playermove"是在其他玩家移动的时候,服务端给我们的,让我们更新其他玩家的位置(这样就能看到其他玩家的移动效果了)。剩下的两个就不用多说了吧。
四.总结
网络流程:
登录流程:
1.客户端登录,发送"newrole"数据包给服务端
2.服务端收到"newrole"数据包,然后发送"newplayer"数据包给其他客户端
3.其他客户端收到"newplayer",向g_other_player列表中添加一个玩家
移动流程:
1.客户端移动,发送"move"数据包给服务端
2.服务端收到"move"数据包,然后发送"playermove"数据包给其他客户端
3.其他客户端收到"playermove",更新g_other_player的相关数据
下线流程:
1.服务端检测到有客户端掉线,发送"logout"数据包给其他在线客户端
2.其他客户端收到"playermove",删除g_other_player中掉线的玩家