2014 年 9 月 23 日
教程:使用 UDP/TCP 进行本地多人游戏
今天的客座教程由 Steelman Games LLC 的创始人 Mark Steelman 提供。Mark 目前正在开发一款包含本地多人游戏的、回合制 RPG,名为 Legend of Us Roleplaying Game(我们传奇角色扮演游戏)。在成为独立开发者之前,Mark 曾在艺电(Electronic Arts)担任游戏设计师四年。您可以在 Facebook、Google+ 上关注他的项目进度,或者订阅他的新闻邮件。
使用 UDP 使设备相互发现
正如在简介中提到的那样,我正在使用 Corona SDK 构建一款回合制角色扮演游戏。作为设计的一部分,我想要实现本地多人游戏,并且希望它是跨平台的。经过大量研究,我弄清楚了如何实现这一点,在本教程中,我将分享我所学到的一些知识。更棒的是:这种架构适用于回合制游戏和动作游戏,所以如果您正在制作动作游戏,也请继续关注!
本教程假设您对 Corona SDK、Lua 和对等网络有基本的了解。我们将使用 LuaSocket,它包含在 Corona SDK 的 socket 库中。因为此设计用于跨平台多人游戏,所以它不会使用任何原生系统。虽然它确实假设玩家拥有局域网 (LAN),但该网络不需要连接到互联网。
包含 LuaSocket
为了使用 LuaSocket,您必须首先包含它
1 |
local socket = require( "socket" ) |
这是简单的部分——让我们继续...
UDP 和 TCP
首先,让我简要解释一下 UDP 和 TCP。这两种协议都允许计算机相互通信,并且每种协议都有一定的优点和缺点。但是,我只会介绍与本教程相关的特性。
UDP 具有向地址发送消息的能力,而无需知道那里是否有任何东西。它不检查消息是否到达任何地方——它只是简单地传输消息。UDP 还允许您侦听地址,而无需连接到该地址的计算机。
TCP 是一种可靠的协议。它通过 TCP 套接字将消息发送到与其连接的另一台计算机。如果另一台计算机响应说消息有问题,它会再次发送消息。
了解这一点后,您可能会问“当 TCP 如此可靠时,为什么还要使用 UDP 呢?”好吧,原因有几个,但与本教程最相关的原因是,您必须知道服务器的 IP 地址才能使用 TCP。任何您无需访问 LAN 管理员即可加入的网络都会使用 DHCP 为您分配一个随机 IP 地址。这意味着我们需要自己发现服务器的 IP 地址。幸运的是,UDP 可以帮助我们做到这一点。
发布服务器
为了使本地多人游戏正常工作,其中一个设备必须充当服务器/主机。主机不需要在您的游戏中具有特殊权限,但正在进行的游戏的主要记录将存储在此设备上。在角色扮演游戏的语言中,让我们称这位玩家为游戏管理员。这位游戏管理员将通过某种方法向本地网络宣布他/她的存在。我使用了一个“邀请”按钮,该按钮在按下时调用以下函数。
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 |
local advertiseServer = function( button ) local send = socket.udp() send:settimeout( 0 ) --这很重要(请参阅下面的注释) local stop local counter = 0 --使用这个,我们可以将我们的 IP 地址宣传一段时间 local function broadcast() local msg = "AwesomeGameServer" --组播 IP 范围从 224.0.0.0 到 239.255.255.255 send:sendto( msg, "228.192.1.1", 11111 ) --并非所有设备都可以进行组播,因此广播也是一个好主意 --但是,要使广播正常工作,网络必须允许它 send:setoption( "broadcast", true ) --打开广播 send:sendto( msg, "255.255.255.255", 11111 ) send:setoption( "broadcast", false ) --关闭广播 counter = counter + 1 if ( counter == 80 ) then -- 8 秒后停止 stop() end end -- 每秒发送 10 次脉冲 local serverBroadcast = timer.performWithDelay( 100, broadcast, 0 ) button.stopLooking = function() timer.cancel( serverBroadcast ) -- 取消定时器 button.stopLooking = nil end stop = button.stopLooking end |
以下是关于此代码的一些说明
- 多播 (Multicast) 是一种设备功能,允许一个设备与多个设备通信。 iPhone 和 iPad 具有此功能,但我被告知 iPod 没有。 我没有在任何 Android 设备上尝试过,所以也许有人可以测试一下并在评论区报告他们的结果。 由于这种不一致性,我们也使用广播 (broadcast)。 你可能会问:“为什么我们不直接使用广播?” 好吧,广播的症结在于局域网必须允许广播。 通过同时使用两者,我们最大限度地提高了相互找到彼此的机会。
- 定时器的“脉冲”是每秒 10 次。 我不建议将您的定时器脉冲设置得更快,除非您有充分的理由 - 毕竟,您的游戏需要时间来做其他事情。 这是包括 MMO 在内的大多数动作游戏的标准脉冲速度。
- 您选择的端口可以是
1
到65535
之间的任何值,但是,应用程序几乎总是会阻止它们使用的端口,如果您尝试绑定到当前正在使用的端口,您会收到错误。 同样,如果您绑定到一个端口,您需要在游戏结束时取消绑定/关闭该端口,这样您就不会在玩家的设备上无限期地阻止它。 较低的端口号由常用的应用程序使用,因此最好使用1024
和65535
之间的端口。 settimeout()
函数允许您告诉套接字在继续之前等待消息的时间。 默认设置为无限期等待,这意味着您的游戏会冻结直到收到消息。 将其设置为0
会告诉它只检查,如果没有要接收的内容,则移动到下一个任务。
查找服务器
客户端需要知道它自己的 IP 地址才能进行下一步。 幸运的是,LuaSocket 中的 UDP 可以解决这个问题
1 2 3 4 5 6 7 |
local getIP = function() local s = socket.udp() -- 创建一个 UDP 对象 s:setpeername( "74.125.115.104", 80 ) -- Google 网站 local ip, sock = s:getsockname() print( "myIP:", ip, sock ) return ip end |
上面函数中的 IP 地址是任意的——我使用 Google 地址是因为我知道它。您甚至不需要连接到互联网,此函数即可返回您的 IP 地址,但您至少必须在本地网络上。
侦听服务器
现在我们准备好侦听服务器了。 我们将通过消息 AwesomeGameServer
识别服务器。 显然,这可以是任何字符串; 我们只是要匹配字符串。
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 |
local function findServer( button ) local newServers = {} local msg = "AwesomeGameServer" local listen = socket.udp() listen:setsockname( "226.192.1.1", 11111 ) -- 这仅在设备支持多播时才有效 local name = listen:getsockname() if ( name ) then -- 测试设备是否支持多播 listen:setoption( "ip-add-membership", { multiaddr="226.192.1.1", interface = getIP() } ) else -- 该设备不支持多播,因此我们将侦听广播 listen:close() -- 首先我们关闭旧套接字; 这很重要 listen = socket.udp() -- 创建一个新的套接字 listen:setsockname( getIP(), 11111 ) -- 将套接字名称设置为真实的 IP 地址 end listen:settimeout( 0 ) -- 如果没有听到任何内容,则继续 local stop local counter = 0 -- 脉冲计数器 local function look() repeat local data, ip, port = listen:receivefrom() --print( "data: ", data, "IP: ", ip, "port: ", port ) if data and data == msg then if not newServers[ip] then print( "我发现了一个服务器:", ip, port ) local params = { ["ip"]=ip, ["port"]=22222 } newServers[ip] = params end end until not data counter = counter + 1 if counter == 20 then -- 2 秒后停止 stop() end end -- 每秒脉冲 10 次 local beginLooking = timer.performWithDelay( 100, look, 0 ) function stop() timer.cancel( beginLooking ) button.stopLooking = nil evaluateServerList( newServers ) -- 使用你找到的服务器做一些事情 listen:close() -- 永远不要忘记关闭套接字! end button.stopLooking = stopLooking end |
我在上面写了很多行内注释,但我要详细说明一些事情
- 请注意,我们考虑到了并非所有设备都支持组播的事实。
receivefrom()
函数会提取该地址上的任何内容,因此我们需要对其进行过滤。这就是为什么我们有字符串消息来进行比较的原因。- 当两个设备相互找到时,如果它们的持续时间都很短,可能会很痛苦。我喜欢让服务器等待的时间比客户端长得多。如果服务器正在广播,客户端会很快找到它们。基本上,我只是想避免“你能再试一次吗?我错过了。”这种情况。
- 在这个例子中,我传入了一个对玩家按下以激活该功能的按钮的引用。我这样做是为了让玩家可以再次按下它并停止广播。如果你不想这样做,则不需要按钮引用。
因此,在这一点上,我们知道如何让玩家发现游戏主机。使用 TCP 所需的基本 IP 地址附加到 UDP 消息中。现在我们有了游戏主机的 IP 地址,我们可以使用 TCP 连接到他们的设备。
交换字符串
现在我们将讨论如何创建一个 TCP 服务器,连接到它,并发送和接收消息。
首先,让我们讨论 TCP 将提供什么和不提供什么。就像我上面所说的,一旦我们在设备之间建立连接,它们就可以来回发送消息。这些消息将只是字符串。想象一下,它就像一个短信应用程序——在这种情况下,一个设备上的应用程序向另一个设备上的应用程序发送短信。然后,这些消息由每个设备上的应用程序解释,并发生某些操作。
安全
本教程不会深入探讨安全性,但应涵盖几个要点
- 服务器和客户端只能在您允许的范围内控制彼此。正如我多次迭代的那样,TCP 只是发送和接收文本字符串。对于可以由第二个设备控制的吃豆人克隆游戏,服务器唯一需要的信息是“开始”、“上”、“下”、“左”和“右”——其他所有信息都可以简单地忽略。
- 您永远不应该尝试让您的应用程序接受已转换为字符串的函数。让客户端和服务器拥有自己的函数,并仅使用传输的文本来调用这些函数。如果你的应用程序接受函数,你就会打开一个非常严重的安全漏洞,所以不要这样做!相反,只需传递带有参数的命令。
无论如何,不要在晚上担心这个。无论是 iOS 还是 Android 都不会让你用这种愚蠢的行为损坏别人的设备,但它可能会毁掉你游戏的安装!
启动服务器
服务器在一个周期性循环中运行。在循环的每次迭代中,它都会检查是否有任何客户端想要加入,以及已连接的客户端是否发送了消息。如果缓冲区有任何要发送的消息,它会发送它们。以下是一个基本的 TCP 服务器模块,并附带进一步的解释
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 |
local S = {} local socket = require( "socket" ) local clientList = {} local clientBuffer = {} S.getIP = function() local s = socket.udp() s:setpeername( "74.125.115.104", 80 ) local ip, sock = s:getsockname() print( "myIP:", ip, sock ) return ip end S.createServer = function() local tcp, err = socket.bind( S.getIP(), 22222 ) -- 创建服务器对象 tcp:settimeout( 0 ) local function sPulse() repeat local client = tcp:accept() -- 允许新客户端连接 if client then print( "发现客户端" ) client:settimeout( 0 ) -- 仅检查套接字并继续 --待办事项:实现一种方法来检查客户端是否之前已连接 --考虑为客户端分配一个会话ID,并在重新连接时使用它。 clientList[#clientList+1] = client clientBuffer[client] = { "hello_client\n" } --仅包含一些要发送的内容 end until not client local ready, writeReady, err = socket.select( clientList, clientList, 0 ) if err == nil then for i = 1, #ready do -- 可用的客户端列表 local client = ready[i] local allData = {} --这保存来自给定客户端的所有行 repeat local data, err = client:receive() --从客户端获取一行数据(如果有) if data then allData[#allData+1] = data end until not data if ( #allData > 0 ) then --确定客户端对服务器说了什么 for i, thisData in ipairs( allData ) do print( "thisData: ", thisData ) --对数据进行处理 end end end for sock, buffer in pairs( clientBuffer ) do for _, msg in pairs( buffer ) do --可能为空 local data, err = sock:send( msg ) --将消息发送到客户端 end end end end --每秒脉冲 10 次 local serverPulse = timer.performWithDelay( 100, sPulse, 0 ) local function stopServer() timer.cancel( serverPulse ) -- 取消定时器 tcp:close() for i, v in pairs( clientList ) do v:close() end end return stopServer end return S |
这是一个基础的服务器。让我们从顶部开始进行一些解释。
socket.bind()
创建一个服务器对象,你将其绑定到你选择的端口。我使用了11111
,但你可以使用我们在前面部分列出的任何端口。记住,当你通过stopServer()
函数关闭服务器时,要关闭 TCP 对象!settimeout( 0 )
告诉 LuaSocket,如果套接字上没有等待的信息,则继续进行。accept()
返回一个客户端对象,该对象表示与另一台设备的连接。每个客户端都会获得自己的对象,并且每个对象都需要在游戏结束后关闭。我们在底部的stopServer()
函数中完成此操作。socket.select()
遍历我们的客户端连接列表,以查看哪些可用。任何不可用的连接都会被忽略,但不会关闭。receive()
接收一行数据。你可以通过在末尾添加\n
在字符串中指定一行数据。这很简单,你可以创建小块的数据。此函数的结构使你最终得到一个编号的字符串行表。它们按照接收顺序进行编号,但是你不能依赖于你发送的行按发送顺序被接收。如果这一点很重要(而且通常很重要),你需要创建一种方法使服务器知道某一行是否顺序正确。- 接下来,我们遍历行列表并解释它们。这通常只是一系列
if
-then
语句,并大量使用 string 库。 - 最后,我们发送缓冲区中的任何内容。缓冲区是另一个字符串列表。同样,你不能完全控制接收它们的顺序。你不必使用缓冲区,但是当你在使用像手机这样的多用途设备作为服务器时,这是一个好主意。你可以在任何时间向客户端套接字
:send()
,但是设备知道消息没有发送出去的唯一方法是,如果其他设备响应。如果其他设备正在通话,它将忽略你的消息,并且消息将丢失。如果你实现一个缓冲区,它会在每个脉冲发送消息,直到发生某些事件将消息从缓冲区中删除,但是你需要实现一种方法来知道何时从缓冲区中删除项目。
连接到服务器
连接到服务器要简单得多
1 2 3 4 5 6 7 8 9 10 |
local function connectToServer( ip, port ) local sock, err = socket.connect( ip, port ) if sock == nil then return false end sock:settimeout( 0 ) sock:setoption( "tcp-nodelay", true ) -- 禁用 Nagle 算法 sock:send( "we are connected\n" ) return sock end |
稍微详细说明一下
socket.connect
非常不言自明:尝试连接到该地址的服务器。settimeout( 0 )
再次让套接字知道,你希望它仅检查套接字,如果没有传入消息,则继续进行。- Nagle 算法是一个标准函数,它使套接字聚合数据,直到数据达到一定大小,然后发送它。如果你只想发送“UP”并希望立即发送它,则需要将其关闭。
此示例中未包括的是确定客户端是首次连接还是重新连接(返回会话)的方法。这超出了本教程的范围,但是一种选择是使用会话 ID,该会话 ID 是客户端首次连接到服务器时获得的。在这种情况下,客户端和服务器都会保存该 ID。然后,如果客户端断开连接,则在重新连接时发送此 ID,并且服务器可以使用新的客户端套接字更新客户端的数据。
客户端循环
最后一部分是客户端循环。这看起来很像服务器循环,但是它永远不会尝试接受连接。
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 |
local function createClientLoop( sock, ip, port ) local buffer = {} local clientPulse local function cPulse() local allData = {} local data, err repeat data, err = sock:receive() if data then allData[#allData+1] = data end if ( err == "closed" and clientPulse ) then -- 如果连接关闭,则重试 connectToServer( ip, port ) data, err = sock:receive() if data then allData[#allData+1] = data end end until not data if ( #allData > 0 ) then for i, thisData in ipairs( allData ) do print( "thisData: ", thisData ) --响应传入的数据 end end for i, msg in pairs( buffer ) do local data, err = sock:send(msg) if ( err == "closed" and clientPulse ) then --尝试重新连接并重新发送 connectToServer( ip, port ) data, err = sock:send( msg ) end end end --每秒脉冲 10 次 clientPulse = timer.performWithDelay( 100, cPulse, 0 ) local function stopClient() timer.cancel( clientPulse ) --取消定时器 clientPulse = nil sock:close() end return stopClient end |
请注意,客户端始终负责建立与服务器的连接。服务器永远不会尝试联系客户端——它已经有足够多的事情要处理了。除此之外,没有其他需要澄清的内容,这些内容在服务器循环部分已经涵盖了。
结论
我希望这个教程能帮助你实现你的多人游戏梦想!虽然它并非旨在成为关于网络的综合教程,但我所完成的工作应该可以帮助你使用 TCP 让两个设备相互通信。
感谢您的阅读,请记住查看Legend of Us 角色扮演游戏,这启发了我弄清楚所有这些。
Lerg
发布于 16:41,9 月 23 日哇。很棒的主题!
egruttner
发布于 19:01,9 月 23 日太-棒-了 !!!
谢谢你,Rob!
Anton
发布于 05:05,9 月 24 日如果能看到工作示例就太好了
simpleex
发布于 01:01,9 月 26 日+1
Bob
发布于 05:39,9 月 25 日Mark,
感谢您分享您的知识和经验。这对许多开发人员应该会有所帮助。
Bob
CK
发布于 09:14,9 月 25 日谢谢你的教程。
这是否也适用于通过蓝牙连接的 2 个设备?
Mark Steelman
发布于 18:21,9 月 26 日蓝牙是一种不同的协议,它就像 TCP 或 UDP,只不过它不需要局域网。就目前而言,我不知道如何让它与 Corona 一起工作,但如果我弄清楚了,我会通知你。
至于演示此代码,只需将上面的所有代码放入“main.lua”文件中即可。然后制作两个按钮,一个调用函数来广播服务器,另一个调用函数来查找服务器。设置服务器循环,以便在您启动应用程序时启动。现在在设备上加载应用程序并运行它。也在模拟器中运行它。在模拟器中按下服务器按钮,在设备上按下客户端按钮(或反之亦然),您将在每个控制台中看到打印消息,这将确认它正在工作。前提是模拟器和设备都在同一网络上。
我没有在教程中详细解释这一点,因为还有其他关于如何制作按钮的教程,而且本教程已经够长了。当然,你不必使用按钮,这只是一种演示代码的简单方法。
Anton
发布于 03:00,9 月 29 日这不起作用,请制作一个示例
Mark Steelman
发布于 07:30,9 月 29 日发布你所做的,我和社区中的其他人将看看我们是否可以帮助你找出你的代码不起作用的原因。
Anton
发布于 01:53,10 月 1 日http://forums.coronalabs.com/topic/51548-local-multiplayer-with-udptcp/
Rob Miracle
发布于 17:00,9 月 25 日所有感谢都归功于 Mark!
CK:我怀疑这是否可以通过蓝牙工作,除非你正在通过蓝牙进行 TCP/IP 网络连接。
Rob
Mark Steelman
发布于 06:36,10 月 8 日我看了上面链接的 Anton 的示例,我需要对我的教程进行一些更正。Rob 将内联修复它们,但我将在此处指出它们,因为它可能会帮助你避免犯同样的错误。
首先,你需要为你的服务器和查找其他设备使用不同的端口。服务器将阻止它正在使用的端口。我们正在将教程中服务器使用的端口更改为 22222,但任何合法的端口都可以。请确保你的代码中不会尝试多次启动服务器,否则你会收到端口阻止错误。
其次,当你在 TCP 上发送字符串时,你需要在其后加上“n”。接收函数在其默认状态下读取一行。你需要告诉它何时该行已结束,否则它将一直等待该行的其余部分,然后才会打印。
Jv
发布于 13:20,5 月 13 日这是否适用于在你的局域网之外连接到你的服务器的人?
Anish Krishna
发布于 06:17,7 月 22 日我正在制作一个需要仅发送和接收数据字节而不是字符串的应用程序...
我该如何做?
我的程序中,字符串在 UDP 上运行良好...
AK
发布于 07:07,7 月 22 日我如何使用 UDP 发送一个字节值(特别是从 0 到 255)并解码它
我能够使用 UDP 成功发送字符串
stevon8ter
发布于 12:34,8 月 6 日我看过这个教程,结果发现它真的很有用。
我正在用 CODEA 进行编码,但这也有 LuaSocket,因此大部分可以重用,感谢这个很棒的教程 🙂
stevon8ter
发布于 10:19,8 月 10 日不过,有一件小事,在“广播服务器”中,有这个
send:sendto( msg, “228.192.1.1”, 11111 )
但 IP 应该是 226. ...
因为客户端也在侦听 226. ... 🙂
Adi
发布于 00:17,9 月 19 日非常有用。感谢分享。
Wesley
发布于 14:14,11 月 3 日我不得不使用以下代码来使本地广播正常工作。它基本上用 255 替换你的 IP 地址的最后一个八位字节。更正确的解决方案可能是基于子网掩码使用正确的广播地址,但我不知道如何在 CoronaSDK 中执行此操作,我相信这将在大多数家庭网络上起作用(希望其他网络支持多播或 255.255.255.255 广播)。
--http://stackoverflow.com/questions/20459943/find-the-last-index-of-a-character-in-a-string
function findLast(haystack, needle)
local i=haystack:match(".*"..needle.."()")
if i==nil then return nil else return i-1 end
end
--用 '255' 替换我们的 ip 地址的最后一个八位字节
function get_local_broadcast_address()
local ip = getIP()
local index = findLast(ip, "%.")
local substr = string.sub(ip,1,index) .. "255"
return substr
end
Adi
发布于 23:31,11 月 5 日很棒的教程。它对我们当前的游戏非常有帮助。
awais
发布于 21:32,11 月 16 日当我在设备上运行时,发生运行时错误(尝试索引局部变量“UDPBroadcast”(一个 nil 值)。)
Mohammad Fakhreddin
发布于 14:07,1 月 21 日我检查了你的代码,更改了你代码的这一部分以使其正常工作
你的 Main.lua
——————–
sendbF = function(event)
— 从服务器获取会话 ID (“sock”)
if(server_ip~=0) then
local sock = connectToServer(server_ip,1235)
createClientLoop(sock,server_ip,1235)
否则
findServer(sendbF)
end
end
Mohammad Fakhreddin
发布于 1月21日 14:05感谢您的教程
我必须说,它需要进行一些更改才能在安卓设备上正常工作
我将您的客户端监听器更改为 0.0.0.0
——————————————
客户端
———————————————————
listen = socket.udp() – 创建一个新的套接字
listen:setsockname( “0.0.0.0”, 1119 ) – 将套接字名称设置为真实的 IP 地址
———————————————————
服务端
————————————————-
print(“广播服务器”)
assert(send:setoption( “broadcast”, true )) – 开启广播
assert(send:sendto( msg, “255.255.255.255”, 1119 ))
assert(send:setoption( “broadcast”, false )) – 关闭广播
Tapas
发布于 2月22日 11:21这很有帮助。但在我的案例中,在 createServer 方法中的以下代码
local ready, writeReady, err = socket.select( clientList, clientList, 0 )
表 ready 为空,而 writeReady 包含客户端数据。你能告诉我这是为什么吗?
Adi
发布于 6月26日 22:59似乎这段代码在最新的 2016.2907 版本中不再起作用。 也许是因为在 2016.2883 版本中更新了 LuaSocket 库。 感谢您就如何修复它提供的任何建议。