1. 为什么在 Love2D 里做联机游戏,我最终选了 sock.lua
如果你跟我一样,是拿 Love2D 做独立小游戏、参加 Game Jam,或者只是想给身边朋友做一个能一起玩的局域网小游戏,那“联机”这个需求迟早会冒出来。Love2D 本身只负责图形、音频、输入和基本物理,它不提供任何网络层能力。想实现联机,你必须自己拉一条网络通信的管道。
市面上的选择其实不少:直接调 C 库 luasocket、用 enet、用 Love2D 官方推荐的 LUBE、或者我本文要讲的 sock.lua。大部分教程会推荐 luasocket,因为资料多、用户量大。但我在实际做项目时最终选了 sock.lua,原因有三个:极简、事件驱动、和 Love2D 的循环模型搭配非常自然。
sock.lua 是一个纯 Lua 实现的小型网络库(默认随 Love2D 发行版里的sock目录也会出现,它是基于 luasocket 封装的更友好接口),核心思路是“注册事件回调 + 发送消息”。它把 socket 的细节藏起来,你在代码里只需要关心:客户端连上了、收到消息了、断开连接了。这种使用方式,比自己在 Lua 层维护 socket 状态机要舒服得多。
这篇博文不会只扔几个 API 就完事,我会从网络模型的选择、协议设计、帧同步与状态同步的取舍、实际调试中踩过的坑,完整走一遍。最终你会得到一个可以双人局域网联机的小游戏框架,而不是几个孤立代码片段。
2. 联机游戏方案选型:sock.lua 的定位与适用边界
2.1 TCP、UDP 和游戏类型的匹配
在正式写代码之前,必须先把网络协议选型聊清楚,因为选错了后面全都白干。sock.lua 支持 TCP 和 UDP 两种传输模式,但默认和文档里更多被使用的场景是 TCP。这跟很多人的直觉判断——游戏就该用 UDP——是冲突的。
对回合制、协作型、非竞技射击类的小游戏来说,TCP 完全够用,甚至更合适。TCP 自带重传、顺序保证,你不需要处理乱序包和丢包重传,代码复杂度会低一个量级。你需要在意的仅仅是延迟出现时的体验问题,而不是数据完整性。
反过来,如果你要做的是 60 帧同步的《以太神斗士》这种格斗游戏、或者 CS 类射击游戏,那 TCP 的队头阻塞和重传机制会给你带来巨大的麻烦。这种项目你更应该用 enet 或自制 RUDP 方案,sock.lua 不是干这个的。
sock.lua 这个库在官方描述里就是“为 Love2D 设计的简单网络抽象层”。我自己的结论是:局域网双人游戏、回合制小游戏、合作类解密、以及原型验证,它非常合适;互联网大并发、竞技同步,它不适合。认清边界,比选择工具本身更重要。
2.2 为什么需要事件驱动的网络层
Love2D 是一个帧驱动的框架:love.update(dt)和love.draw()每帧被调用。如果你的网络层是阻塞式的,比如直接调用socket.receive(),一收到不到消息就会卡住主循环,画面直接冻结。这不是体验问题,是根本没法用的问题。
sock.lua 的处理方式是事件驱动:它内部在后台循环/回调机制里监听 socket,当网络事件发生时,触发你在sock.on()里注册的函数。这意味着你的主循环不需要停下来等网络。这个模式,和我写游戏时的思维模式是一致的:网络是外部输入源,输入来了就响应,没来就继续跑游戏逻辑。
这里有一个非常重要的设计原则,也是我在实际项目里吃了亏才总结出来的:不要在收到消息的回调函数里直接修改游戏状态。原因在于 Love2D 的主循环是单线程的,网络事件回调可能在帧更新的中途被触发,如果在这个时候修改玩家坐标、血量这类共享数据,容易造成“帧内状态漂移”。我的做法是:在所有回调函数里只把事件塞进一个队列,然后在love.update里统一处理队列里的消息。这个模式后面我会给出完整代码。
2.3 sock.lua 核心 API 一览
sock.lua 的接口很少,这既是优点也是缺点。优点是学习成本极低,缺点是更需要你自己去组织消息格式。下面是我经常用到的一组 API:
sock.newServer(port):创建一个服务端监听对象sock.newClient(address, port):创建一个客户端连接对象server:listen():服务端启动监听client:connect():客户端发起连接sock.send(data):给对端发送数据sock.on(事件名, 回调函数):注册事件处理回调sock.think(dt):在 Love2D 的 update 中调用,用来驱动网络状态机处理收发事件
具体细节不同版本可能略有差异,但核心模型一致。我建议你拿到任何库之后,第一件事不是找教程,而是直接打开它的源码文件看一眼,这类小库通常一两百行,看明白之后,你对它的运行机制就没什么黑盒了。这比反复看别人的示例代码要有效得多。
3. 动手前必须想清楚的三件事:消息格式、连接模型和同步策略
3.1 消息格式设计:不要传裸字符串
很多第一次做联机的新手,会直接写出这样的代码:
sock.send("42,1337,1")然后服务端收到后用string.match去拆。这在小样本测试时没问题,但一旦消息种类变多,比如除了移动,你还要同步玩家名称、血量、道具 ID、聊天消息,这种字符串拼接的方式会让代码极难维护。调试的时候你根本分不清哪个字段是哪个。
我推荐的做法是:在数据包里显式带上消息类型,用分隔符或者序列化工具处理数据部分。看起来多写几行,但对后续扩展至关重要。
更推荐的做法是使用serializer.lua这类的库(Love2D 社区常有的一个小型序列化模块),它能把 Lua 的 table 转成可传输的字符串,然后在对面重新还原成 table。这样你发送消息的时候,可以写:
sock.send(serializer.serialize({ type = "move", x = x, y = y }))接收端还原:
local msg = serializer.unserialize(data) if msg.type == "move" then -- 更新玩家位置 end这种方式的优点,是消息体变成一个结构化的 Lua table,而不是一堆需要手动 parse 的字符串。当然代价是数据体积更大、解析有 CPU 开销。对局域网和轻量游戏来说,这点开销完全可以接受。如果你对性能极度敏感,才需要走“预定义二进制协议 + 位运算编码”那条路,但那是另一个复杂度等级了。
3.2 连接模型:CS 模式永远比 P2P 简单
有相当多的新手在做联机游戏时,第一反应是“两个客户端直接互相连接”。这个模型叫 P2P,看起来公平,但实际上要解决一个非常棘手的问题:NAT 穿透。在绝大多数家庭网络环境中,两个客户端都处在路由器后面,想直接互通,需要一个中间的协助服务器做打洞,而打洞并不保证成功。
如果你只是做一个和熟人联机的小游戏,老老实实用CS(Client-Server)模式:一个玩家开一个房间(跑服务端),其他人作为客户端连接进来。这个模式在网络层只需要做一件事:客户端能主动连到服务端。而主动出站的连接,在绝大多数网络环境下都是被允许的。服务端这边你是玩家自己,不需要开公网 IP,只需要在同一局域网内,或者通过映射工具把端口暴露出去(这个在我们自己的项目里通常不是刚需,因为大多数场景就是局域网里玩)。
我做联机小游戏的经验是:别一上来就追求分布式架构,先跑通一个 CS 模式,能在局域网里稳定联机,已经能覆盖 90% 的实际需求了。
3.3 状态同步与帧同步:选择难度更低的那条路
联机游戏有一些经典的同步策略,最常见的是状态同步和帧同步。我这里用自己的话解释一下区别:
状态同步:每帧或者每一定频率,客户端把“我的状态”(位置、朝向、动作等)发到服务端/其他客户端;收到后,把对方代理对象的显示状态更新过来。逻辑简单,允许一定延迟,断线重连好处理。
帧同步:所有客户端在同一个逻辑时钟下,执行完全一致的输入序列。每个客户端只上传自己的操作指令(前进、跳跃、攻击),然后所有客户端各自跑同一套逻辑代码,推算出完全一致的结果。这种方案带宽要求极低,但是逻辑必须确定,不允许浮点数误差,任何一个小数点差异都会导致后期“裂缝”。实现复杂度比状态同步高一大截。
对 sock.lua + Love2D 的常规项目,我的建议是:无脑选状态同步。帧同步适合的是 RTS 和格斗游戏,在这样的游戏里操作密度高、状态可预测性差,带宽是瓶颈;而你的双人合作闯关、图版游戏、休闲对战,状态同步完全够用,代码量少,还更容易调试。
我在第三节里先建立这些概念,是因为无论你用什么网络库,这些设计决策都绕不开。编程语言的差异是皮毛,同步模型和数据流的设计才是联机游戏的骨骼和血肉。
4. 实操:搭建一个双人局域网联机骨架(完整代码)
4.1 服务端与客户端的共用配置文件
我习惯把共用配置抽出来,比如一个config.lua:
-- config.lua return { port = 22122, tickRate = 30, -- 每秒状态同步次数 playerRadius = 20, }端口选择一个高位端口,比如 22122,避开常见服务端口冲突。tickRate 决定你多久同步一次位置,30 就是每秒 30 次状态更新,局域网下足够平滑。这个数值你完全可以自己调,但注意,不是越高越好,越高意味着带宽占用越大,而且超过你游戏帧数的更新率毫无意义。
4.2 服务端骨架:开房间的那一方
服务端做的事情:监听端口、接受客户端连接、创建玩家对象、转发玩家状态给所有客户端。
-- server.lua local sock = require("sock") local serializer = require("serializer") local config = require("config") local messages = {} local players = {} function love.load() local server = sock.newServer(config.port) server:listen() sock.on("connect", function(client) print("玩家连接: " .. client) -- 为新玩家分配一个 ID 和初始坐标 local id = #players + 1 players[id] = { id = id, x = 200, y = 200 } -- 通知新玩家,自己的 ID 是多少 sock.send(serializer.serialize({ type = "welcome", id = id })) -- 把所有现有玩家位置广播给新玩家 sock.send(serializer.serialize({ type = "players", list = players })) end) sock.on("receive", function(client, data) local msg = serializer.unserialize(data) if msg.type == "move" and players[msg.id] then players[msg.id].x = msg.x players[msg.id].y = msg.y end end) sock.on("disconnect", function(client) print("玩家断开: " .. client) -- 查找并移除对应玩家,这里需要 client 与 ID 的映射,实际项目中要做好 end) end function love.update(dt) sock.think(dt) -- 每秒按 tickRate 广播当前所有玩家状态 sock.send(serializer.serialize({ type = "players", list = players })) end function love.draw() for _, p in pairs(players) do love.graphics.circle("fill", p.x, p.y, config.playerRadius) end end注意,务必要在love.update里调用sock.think(dt),这一步是驱动的核心。很多新手把sock.on注册好了就觉得万事大吉,结果程序一跑发现什么回调都不触发,八成就是漏了sock.think。
另外,我在上面的代码里做了一个简化:直接sock.send给所有连接,这在实际的 sock.lua 中通常只能发送给默认/最后一个客户端。真实项目中,你应该服务端保存所有 client 对象,然后逐个调用client:send()。我会在第 5 节给出这个修正版。
4.3 客户端骨架:加入房间的那一方
客户端的任务:连接服务端、读取本地输入、把自己的操作发送出去、根据服务端广播更新画面。
-- client.lua local sock = require("sock") local serializer = require("serializer") local config = require("config") local selfId = nil local players = {} local inputQueue = {} function love.load() local client = sock.newClient("127.0.0.1", config.port) client:connect() sock.on("connect", function() print("已连接服务端") end) sock.on("receive", function(data) local msg = serializer.unserialize(data) if msg.type == "welcome" then selfId = msg.id elseif msg.type == "players" then -- 这里只做赋值,不直接改游戏对象, -- 但在简单示例里我直接更新了 players 表,因为条件根本没运行帧内逻辑 players = msg.list end end) end function love.update(dt) sock.think(dt) if selfId then local dx, dy = 0, 0 if love.keyboard.isDown("up") then dy = -1 end if love.keyboard.isDown("down") then dy = 1 end if love.keyboard.isDown("left") then dx = -1 end if love.keyboard.isDown("right") then dx = 1 end if dx ~= 0 or dy ~= 0 then local me = players[selfId] if me then me.x = me.x + dx * 200 * dt me.y = me.y + dy * 200 * dt sock.send(serializer.serialize({ type = "move", id = selfId, x = me.x, y = me.y })) end end end end function love.draw() for _, p in pairs(players) do love.graphics.circle("fill", p.x, p.y, config.playerRadius) end end这段客户端的逻辑是:本地玩家先移动自己的圆,然后把新的坐标发给服务端;服务端在收到后更新它保存的该玩家状态,并且以固定频率把所有玩家状态广播给所有人。其他客户端收到广播后,覆盖本地显示的玩家列表。这就是一个最基础的状态同步。
不过,这个简单版本有一个明显问题:本地玩家移动后,自己也会覆盖更新,可能产生轻微的位置回跳,因为本地更新和服务端广播之间的顺序有竞争。实际项目中我会加一条 id 判断:如果是自己,就用本地预测位置;如果是别人,用服务端广播位置。这个优化,我称之为“客户端预测”,是提升手感的关键。
4.4 消息队列,而不是直接回调处理
我在前面提到“回调里不直接改游戏状态”,现在给出完整实现。做法是定义一个networkEvents队列:
local networkEvents = {} function love.load() sock.on("receive", function(data) -- 只入队,不做任何事 table.insert(networkEvents, { type = "receive", data = data }) end) sock.on("connect", function(client) table.insert(networkEvents, { type = "connect", client = client }) end) end function love.update(dt) sock.think(dt) -- 统一处理网络事件 for _, ev in ipairs(networkEvents) do handleNetworkEvent(ev) end networkEvents = {} end function handleNetworkEvent(ev) if ev.type == "receive" then local msg = serializer.unserialize(ev.data) -- 这里才真正进入游戏逻辑 end end这个模式看起来只是多了一层中转,但它在真实项目里保护了你。因为一旦你的游戏逻辑复杂起来,比如有物理模拟、有动画状态机、有输入缓冲,你在回调里直接操作这些状态,很容易触发一些只在特定帧序下才会出现的诡异 Bug。用队列把事件收集到帧更新的固定时点再处理,能保证每一次状态变更都在你完全可控的顺序上发生。
4.5 关于 serialization 的坑
serializer.lua 这个库好在使用简单,但也有它的问题:它无法处理循环引用表,遇到函数、userdata 也会报错。所以你在打包消息时,务必只打包纯数据。简单说,不要把任何对象方法、Physics Body 引用塞进消息里,只传位置、分数这类基本数值。
如果消息量变大,我建议你用 JSON 库(Lua 的 json.lua)替代 serializer.lua,虽然体积稍微大一点,但可读性和跨语言兼容性更好。如果将来你干脆想用 Node.js 写一个专用服务端,那用 JSON 就会让你无缝对接,而 serializer.lua 的格式是自成体系的,别人接不上。
5. 多玩家与房间管理:从双人扩展到多人
5.1 服务端的连接映射
在第 4 节的简化代码里,我犯了一个“错误”:没有把client和玩家 ID 绑定。这在双人场景下不太致命,但一旦超过两个客户端,你就会发现广播和踢人根本无从谈起。正确的做法是建立一个clients表,让 client 对象成为玩家数据的 Key:
local clientToPlayerId = {} local players = {} -- id -> 玩家数据 sock.on("connect", function(client) local id = #players + 1 players[id] = { id = id, x = 100 * id, y = 100 } clientToPlayerId[client] = id -- 现在你有多个客户端,广播时遍历所有 client end) sock.on("disconnect", function(client) local id = clientToPlayerId[client] if id then players[id] = nil clientToPlayerId[client] = nil -- 广播一个玩家离开的消息,让其他客户端清除该玩家 end end)这个设计,把 client 句柄和游戏逻辑中的玩家 ID 做了一个映射层。无论将来做断线重连、房间踢人、还是玩家改名,你都会需要这个映射。千万别偷懒省略。
5.2 广播的两种方式
sock.lua 里,如果是 TCP 模式,广播的正确姿势是:遍历所有已连接的客户端,逐个调用client:send。写的时候注意,不要在遍历过程中直接删除表项,否则你会跳过内容。我通常先把要断开的 client 收集到一个列表,遍历结束后统一清理。
另外还有一个更高效的广播思路:如果是 UDP 模式,你可以直接向所有客户端地址发送数据包,不需要逐个维护连接。但 UDP 在 sock.lua 里的 API 和 TCP 有所不同,而且你要自己处理丢包、乱序。所以我还是建议多数场景用 TCP。
5.3 房间与匹配
如果你的游戏有大厅概念,比如“创建房间”“加入房间”,那在服务端的玩家状态里加一个roomId字段即可。服务端逻辑会变成这样:
- 客户端发送
createRoom消息,在服务端创建一个房间号,并返回 - 客户端发送
joinRoom消息,带上房间号,服务端把该 client 加入对应房间 - 广播时只发给同房间的客户端
房间就是一张表,存 roomId 和成员列表。这是实现多人游戏厅的基础。我做一个类似“跳棋大厅”的原型时,就只用了一个 Lua 表存房间,代码量并不大。真正的复杂度来自断线处理,比如房间主人掉线后,房主权利怎么转移,这个必须提前想好。我的处理是:房间创建者掉线后,自动把房间里的最早加入者设为新房主,并且广播给所有人。
6. 实际联机调试中的常见问题与排查技巧
6.1 连不上服务端的排查清单
这是所有联机开发里遇到最多的一个问题。两个玩家在同一局域网,但客户端连不到服务端,排查顺序我一般是这样:
第一,先检查服务端是否真的在监听。命令行用netstat -an | grep 22122(Windows 用netstat -ano | findstr 22122)看端口是否处于LISTENING状态。如果你用了防火墙,还要确认防火墙没有拦截 Love2D 进程。
第二,检查客户端填写的 IP。注意不要把局域网内网 IP 和127.0.0.1混淆。127.0.0.1只能用于本机自测,真正的局域网联机要填服务端那台机器的局域网 IP,比如192.168.1.101。获取方法很简单,在服务端机器上运行ipconfig(Windows)或ifconfig(Mac/Linux)查看无线网卡或以太网卡的 IPv4 地址。
第三,检查服务端代码里用的端口和客户端连接的端口是否一致。这种低级错误居然是我见过最多的问题之一,多半是随手改了一个没改另一个。
6.2 粘包和半包问题
使用 TCP 时,你通过sock.send发送的一条消息,在接收端不一定以同样的边界被收到。原因是 TCP 是流式协议,它会根据内核缓冲区和网络状况决定把多少字节作为一个 segment 交付应用。你连续发送的两条消息可能被合并成一个字节流(粘包),也可能一条消息被拆成两次到达(半包)。
serializer.lua 产生的是自描述的数据串,但仍然不保证底层 socket 的 read 边界和你的逻辑消息边界一致。所以我在实际项目中,都会在消息外面再包一层长度头:前4字节表示数据长度 + 数据内容。接收时,先读满 4 字节,得到长度,再读满这么多字节,才认为这是一条完整消息。
这也是 sock.lua 这种“半封装”库容易让人踩的坑:它帮你解决了很多 socket 的底层麻烦,但没有帮你处理这个 TCP 流式协议的经典问题。如果你用的是 UDP 模式,则不存在这个问题,每个包天然有边界,但换来的是你需要处理乱序和丢包。
6.3 延迟导致的坐标抖动
状态同步下,一个很常见的现象是:对方玩家的角色在屏幕上抖动,位置一跳一跳的。这通常有两个原因。第一,你的状态广播频率太低,或者网络波动使得到达时间不稳定。我建议固定 tickRate,比如服务端无论真实帧率多少,都按固定的时间间隔发送状态广播。第二,客户端接收更新直接赋值坐标,不做插值。
解决抖动最简单有效的方法,是给远程玩家做位置插值:维护一个旧位置和一个目标位置,在每一帧里让旧位置向目标位置平滑移动。插值速度可以根据dt和一个插值系数决定,比如position = position + (target - position) * min(1, dt * 10)。这样即使状态包到达有一些抖动,表现在画面上也会平滑很多。
6.4 网络聊天的实现
如果你要给游戏加一个聊天系统,本质就是广播一条带发送者名字和文本内容的消息。但要注意一个点:不要相信客户端发来的任何标志字段,比如客户端声称“我的名字是 Admin”这个字段可以被伪造。真正可靠的来源是你的服务端在用户连接时记录的 ID。我在做一个社交小游戏时就吃过教训,一个玩家在客户端循环改自己的名字再发出来,聊天栏直接被刷爆。解决办法是,房间里基于连接 ID 做频率限制,比如每秒最多发 3 条聊天消息。
6.5 测试环境搭建的心得
我见过很多项目,代码逻辑挺好,但就是没法稳定联机,原因是开发机上自己测自己,天然避开了真实网络环境的问题。建议至少找两台真机,一台开热点,另一台连过来,模拟真实局域网。这个环境能暴露的问题比你在本机模拟多得多:防火墙、IP 配置、路由延迟、TCP 缓冲差异都会体现出来。
7. 完整可运行的示例:局域网双人接球游戏
有了前面的基础,我直接给你一个完整的、能跑的双人局域网游戏骨架。玩法很简单:两个玩家各控制一个小球,互相追着跑,碰到对方得一分。这个示例覆盖了本文所有的知识点。
7.1 服务端完整代码
这里我给出带长度头(简化版用“消息分隔”代替更合理)的 TCP 服务端版本。为了减少粘包问题,我这里用了一个比较务实的分包策略:每个数据包以\n结尾,发送时保证内容里不会出现\n(serializer 输出如果含换行,用 base64 或者找其他分隔符)。
但不建议靠分隔符做边界,所以直接在 serializer 外面再包一层长度前缀的实现,就是下面这样:
-- server.lua local sock = require("sock") local serializer = require("serializer") local config = require("config") local clients = {} -- client -> 连接元信息 local players = {} -- id -> {id,x,y,score} local nextId = 1 local function sendTo(client, msg) local data = serializer.serialize(msg) local len = #data local header = string.char( math.floor(len / 256), len % 256 ) client:send(header .. data) end local function broadcast(msg) for client in pairs(clients) do sendTo(client, msg) end end function love.load() local server = sock.newServer(config.port) server:listen() sock.on("connect", function(client) local id = nextId nextId = nextId + 1 clients[client] = id players[id] = { id = id, x = 100 + id * 100, y = 300, score = 0 } sendTo(client, { type = "welcome", id = id, players = players }) broadcast({ type = "playerJoin", id = id, x = players[id].x, y = players[id].y }) end) sock.on("receive", function(client, data) -- 这里就是带长度头的底层字节串 -- 为了示例,简化处理:每次回调假设一条完整消息 local id = clients[client] if not id then return end local msg = serializer.unserialize(data) if msg.type == "move" then players[id].x = msg.x players[id].y = msg.y end end) sock.on("disconnect", function(client) local id = clients[client] if id then players[id] = nil clients[client] = nil broadcast({ type = "playerLeave", id = id }) end end) end local tickTimer = 0 function love.update(dt) sock.think(dt) tickTimer = tickTimer + dt if tickTimer >= (1 / config.tickRate) then tickTimer = 0 broadcast({ type = "players", players = players }) end end7.2 客户端完整代码
客户端接球逻辑,我简化成两个圆碰到的检测:
-- client.lua local sock = require("sock") local serializer = require("serializer") local config = require("config") local selfId = nil local players = {} local collectedMessages = {} local function sendMessage(msg) local data = serializer.serialize(msg) local len = #data local header = string.char(math.floor(len / 256), len % 256) sock.client:send(header .. data) end function love.load() local client = sock.newClient("127.0.0.1", config.port) client:connect() sock.client = client sock.on("receive", function(data) table.insert(collectedMessages, data) end) end function love.update(dt) sock.think(dt) -- 处理所有收到的消息 for _, data in ipairs(collectedMessages) do if #data < 2 then goto continue end local len = string.byte(data, 1) * 256 + string.byte(data, 2) local payload = string.sub(data, 3, 2 + len) local msg = serializer.unserialize(payload) if msg.type == "welcome" then selfId = msg.id players = msg.players elseif msg.type == "players" then players = msg.players elseif msg.type == "playerJoin" then players[msg.id] = { id = msg.id, x = msg.x, y = msg.y, score = 0 } elseif msg.type == "playerLeave" then players[msg.id] = nil end ::continue:: end collectedMessages = {} if not selfId then return end local me = players[selfId] if me then local dx, dy = 0, 0 if love.keyboard.isDown("up") then dy = -1 end if love.keyboard.isDown("down") then dy = 1 end if love.keyboard.isDown("left") then dx = -1 end if love.keyboard.isDown("right") then dx = 1 end if dx ~= 0 or dy ~= 0 then local speed = 250 me.x = me.x + dx * speed * dt me.y = me.y + dy * speed * dt sendMessage({ type = "move", x = me.x, y = me.y }) end end -- 接球检测(示意) for id, p in pairs(players) do if id ~= selfId and me then local dist = math.dist(me.x, me.y, p.x, p.y) if dist < config.playerRadius * 2 then me.score = me.score + 1 p.score = p.score + 1 -- 简化版,实际应由服务端判定分数 end end end end function love.draw() for _, p in pairs(players) do if p.id == selfId then love.graphics.setColor(0, 1, 0) else love.graphics.setColor(1, 1, 0) end love.graphics.circle("fill", p.x, p.y, config.playerRadius) love.graphics.print("P" .. p.id, p.x - 10, p.y - 30) end if players[selfId] then love.graphics.print("My Score: " .. players[selfId].score, 10, 10) end end这个示例里把分数判定放在了客户端,这是不安全的,我给你说明白:真正的分数判定必须在服务端做。但客户端本地先行显示,是很多实时游戏采用“客户端预测”的体现。麻烦在于,如果服务端分数和你本地不一致,你需要“权威修正”。这个复杂度我留给你们去探索。
7.3 运行方式
- 将 sock.lua 放到项目的根目录下,确保
require("sock")能找得到它 - 准备两个窗口,一个运行
love server,一个运行love client - 服务端窗口会输出“玩家连接”相关日志
- 客户端可以用方向键移动绿色小球,另一个窗口可以看到黄色小球在动
要注意的是,我上面的客户端写死了127.0.0.1,局域网联机时你需要改成服务端电脑的局域网 IP,或者干脆在config.lua里加一个serverIP字段,让用户自己填。
8. 提升工程质量:心跳、韧性与防作弊意识
8.1 心跳与断线检测
TCP 有一个天然问题:对端异常断电、网线拔掉这种“非正常断开”不会立即触发断开事件。如果你的服务端只依靠disconnect回调,那你会发现玩家走了但角色还在游戏里。解决方案是心跳机制:服务端每隔几秒给客户端发一个 ping 包,客户端收到后回一个 pong 包。如果服务端连续若干次没有收到 pong,就判定该客户端掉线,清理数据。
心跳包本身不复杂,就是在收发逻辑里增加type = "ping"和type = "pong"两个分支。但要注意,心跳频率不宜过高,对局域网游戏,5 秒一个周期、连续丢 2 次判定掉线,已经可以接受。对互联网环境,你可以酌情缩短到 3 秒。
8.2 数据包大小与频率控制
我见过有人写的广播代码里面,每帧发送 30 个玩家各摆 50 个坐标点的完整状态。那个包瞬间就能到几十 KB,局域网虽然能扛住,但多一个客户端就多一份带宽,你的消息处理时延也会上升。经验值是:状态同步包控制在 1KB 以内,广播频率一半 20-30 Hz 足够。
另外,每个客户端产生的输入消息,发出去的频率应该跟本地帧率分开,别直接绑死在love.update的每一帧里。帧率是 60 而你的同步频率只要 30,那你就应该用一个累加器,只在到时间时才发送状态包,否则你会白白多占一倍的带宽。
8.3 服务端权威逻辑:永远不要在客户端判定结果
这是我在文章里反复强调的最后一个原则。客户端发来的任何信息都不可信。玩家位置、血量、分数、碰撞结果,这些都应该以服务端的计算为准。客户端只能发送“意图”,比如我按了跳跃键、我朝这个方向走,然后由服务端去计算实际发生什么,再把结果广播下去。
这不是为了防外挂这么简单,也是为了代码一致性。当所有玩家看到的状态都来自同一个权威来源时,你不需要处理“两个客户端因为模拟误差带来的分歧”。服务端权威化可能让你多写一点代码,但它能省掉你大量后续调试的功夫。
9. 我的经验总结,和后续可以扩展的方向
做了几个 Love2D 联机小项目之后,一个很深的感受是:联机游戏的难点,从来不在“怎么把字节从 A 送到 B”,而在于你怎么组织游戏状态、怎么控制消息流、怎么处理真实网络中各种不理想的情况。sock.lua 帮你解决的是最底层那 20% 的问题,剩下 80% 是设计问题。
从我个人的实操体会来说,sock.lua 很适合“验证联机玩法”这个阶段。你不需要搭建一个庞大的服务器基础架构,不需要学习复杂的网络框架,两三个文件就能让一场双人游戏跑起来。这正是独立游戏开发者最需要的:用最小成本验证核心玩法是否有乐趣。
如果你要把这个项目继续往下做,我个人建议的顺序是这样:先把服务端改成“权威服务器”,把分数、碰撞判定都收回去;然后加入心跳检测和断线重连;再往后,如果你的玩家数量超过 4 人,就要认真考虑服务端和客户端的代码拆开了,因为到时候你控制的不只是一台机器上的状态,而是一整个实时共享的世界。有一点我始终记得:联机游戏不是你写完一份代码,在两台机器上跑起来就万事大吉。它更像是在维护一个持续运转的实时系统,玩家体验的好坏,往往取决于你对一个毫秒级延迟和一个异常断线的处理态度。这也是为什么我说,sock.lua 的源码值得你打开读一遍,它的小巧不是一个局限,而是给你留出了看清全局、掌控细节的空间。