Java&Lua游戏服务器战斗框架

闲聊

游戏服务器

现在游戏服务器已经非常普遍了,在游戏行业早期,服务器大部分都还是C或者C++,以追求更高的执行效率。而那个时候的Java,还被认为只能处理Web开发这样的对延时要求稍低的应用。

谁知道几年后,编程语言遍地开花,别说Java了,Go,Python,NodeJs(JavaScript),甚至PHP都能作为游戏服务器了。老大哥C++虽然执行效率最高,但开发效率却很低,更不用说维护成本和人才成本。而近几年新起的Go语言算是新起之秀,凭借它语言级的并发能力获得一票支持。

相对来说,Java算是稳步发展,在十几年的版本迭代升级中,把自己的IO,多线程,内存,GC,效率等一系列都进行全面升级,如今也算是在服务器这块站稳了脚本,相对来说在市场上的相关从业人员也是最多的。得益于Java完善的开源社区的发展,多到数不清的第三方开源框架也让Java的生态变得非常完善了。

所以如果现在要开发一个游戏服务器,从开发效率,人才成本,维护成本和性能等综合考虑来看,Java再适合不过了。

游戏客户端

回过头来看,游戏的客户端也一样经历了一系列变化。最早的PC的游戏客户端,可能大部分也都是C或者C++ 进行开发的,后来进入Web时代之后,出现了用Flash开发的页游。后来,大佬们发现,大部分的游戏,都是那些东西,图形,动画,特效,物理等等,于是出现了游戏引擎。一开始的游戏引擎可能也都是公司自用。到现在,大家也基本都知道了,出现了Egret、Laya、Cocos、Unity、Unreal等游戏引擎,它们也分别支持着不同的语言,有JavaScript、C#、C++等。

以上这么一大段,其实也只是想说一下,不管是前端还是后端,都有着很多种的语言。那能不能让前后端使用同一种语言呢?这样一来,我们写一套游戏的逻辑代码,不就能既跑在客户端,又跑在服务器了吗,而不用分别在两种语言上分别实现了吗?

事实上,这也并不是不可行的,比如C++,C#,JavaScript这几种语言,就既能开发服务器,又能开发客户端。然而实际上,在组建团队的时候,想要找到刚好前后端技术栈都匹配的技术人员,也并非易事。

Java&Lua方案

我目前的项目,其实就面临这样一个问题,想要让服务器和客户端使用同一个语言开发同一套游戏战斗逻辑层代码:当我们想让游戏以单机运行的时候,这套逻辑层代码可以完全跑在客户端;而当我们想让游戏以联机运行的时候,我们就让这套战斗逻辑代码跑在服务器,仅对客户端作状态同步的表现。可是我们服务器是Java,客户端是Unity,怎么办呢?我们用了一个中间语言——Lua,来作为逻辑层的代码开发。Unity中使用Lua还算比较容易,因为它本身是支持启动Lua虚拟机的。但是要在Java中调用Lua,就稍微有点头疼了。

搜索了网上的解决方案,方案不是很多,其中Luaj算是使用起来最顺手,运行效率也相对较高的方案,不过Luaj使用起来也有很多坑(后面细聊)。
通过Luaj的官网或者github可以详细了解luaj的相关API和实现细节。

Lua共战逻辑框架

经过研究和讨论后,我们最终得到的前后端的共战框架开发模型是这样的
Lua共战框架模型

在实际运行中,我们会分别在服务端和客户端启动一套战斗逻辑Lua代码,我们可以通过配置分别以两种模型启动战斗:

  1. 单机模型
    单机模型
    Client Lua:运行完整战斗逻辑

这种模式相对比较容易理解,即在客户端运行完整的战斗逻辑

  1. 联机模型
    联机模型
    Client Lua:运行部分战斗逻辑,对服务器的状态同步消息进行本地逻辑校正
    Server Lua:运行完整战斗逻辑,并进行状态同步广播

这种模式即状态同步模式,主要逻辑都在服务端运行,并且通过网络通信把运行后的状态同步到客户端,而客户端也可以通过同样的Lua逻辑代码进行逻辑的预演算,待收到服务端同步过来的状态后,再对预演算的结果进行校正

Java-Lua 战斗开源框架

基于这套框架,我对现有的Java&Lua战斗框架做了独立于业务的抽象,并做了开源。
地址:https://github.com/hjcenry/lua-java-battle

Powered

基于luaj实现的java使用lua的战斗框架

该框架基于luaj的二次封装实现(https://luaj.sourceforge.net

提供功能

  • luaj基础接口的调用封装
  • 简化luaj环境搭建步骤
  • 管理lua战斗并提供接口
  • lua面向对象使用方案(class.lua)
  • lua战斗框架使用示例
  • luaj踩坑指南

该框架提供Java-Lua的战斗框架,有以下优缺点

  • 优点
  1. 公用逻辑lua代码:前后端可以基于同一套语言使用同一套战斗逻辑代码,只需要设计好共战框架,即可实现一份代码两处使用。前后端程序员也可以基于这套框架共同开发,这无论是对于状态同步还是帧同步来说,都可以一定程度提升开发效率。
  2. luaj框架相比其他java调用lua方式,是目前为止效率最高的。
  3. lua代码无需编译即可直接使用,可以通过luaj设计一套热更逻辑
  • 缺点踩坑指南):
  1. 占用jvm更多的堆和meta空间

luaj提供两种编译器,luac和luajc。其中luac在load文件之后,会创建一个LuaClosure对象,其运行过程中会逐行解析lua命令,当然其运行效率也不会太高。
而luajc的原理是通过编译成Java字节码,并通过它的JavaLoader(继承ClassLoader)加载到内存,相当于一次编译多次运行。
但是熟悉的Java类加载机制的朋友应该清楚,这个过程中,JVM会在meta空间创建Klass信息,并在ClassLoader保存Klass引用。与此同时,luaj的JavaLoader也做了一件事:缓存动态生成的字节码byte[]
这种情况下,启动一个lua环境则会增加JVM的堆和meta空间的占用。

  1. 运行效率不如原生Java

luaj的作者描述luaj的运行效率基本和原生lua虚拟机运行效率相当,甚至反超。但在我的实际测试中,我没有拿luaj和原生lua对比,而是拿luaj和原生java相比,其性能是远不如原生Java的。
通过观察luaj编译后的源码也能发现,拿i++这样一个操作来举例,原本在Java中,是可以直接对基本数据类型int进行操作的,而在luaj中,会对int进行类似Integer的包装类(LuaInteger)进行包装。

  • 既是优点也是缺点:
  1. 灵活的lua代码

灵活是一把双刃剑,用好了可以大幅提升开发效率,而用的不好的话,不仅不能提升开发效率,还可能对开发和维护,都带来极大的痛苦,这非常考验底层开发的能力。

综上所述:是否使用lua作为Java服务器的战斗逻辑代码,需要根据实际情况而定,它的优点是否给你代码巨大好处,同时你也能忍受它的缺点或者有其他方案克服它的缺点。

欢迎大家使用,有任何bug以及优化需求,欢迎提issue讨论

Java Doc

https://hjcenry.com/lua-java-battle/doc/

快速开始

完整代码示例可以参考BattleDemoService

maven地址

<dependency>
    <groupId>io.github.hjcenry</groupId>
    <artifactId>lua-java-battle</artifactId>
    <version>1.0</version>
</dependency>

1. 指定Lua参数

LuaInit.LuaInitBuilder luaInit=LuaInit.builder();
luaInit.preScript("print('Hello Lua Battle!!!')");
// 设置lua根路径
luaInit.luaRootPath("F:\\project\\lua-java-battle\\src\\main\\lua\\");
// 加载lua调用接口目录
luaInit.luaLoadDirectories("interface");
// 加载lua主文件
luaInit.luaLoadFiles("FightManager.lua");
// 展示log
luaInit.showLog(true);

2. 初始化Lua环境

LuaBattleManager.getInstance().init(luaInit.build());

3. 初始化并缓存Java调用的Lua方法

// 初始所有需要用到的方法
this.xxxFunction=this.initFunction("XXX.xxx");
this.xxxFunction2=this.initFunction("xxx");

4. 调用Lua方法

this.xxxFunction.invoke();
this.xxxFunction2.invoke(LuaNumber.valueOf(123));

以上是简单的示例这个框架应该如何使用,BattleDemoService中提供了一套比较完成Lua战斗框架的示例

使用方法以及例子

  1. 战斗Service示例
  2. Lua-Java数据转换工具使用示例
  3. Lua-Java库转换工具使用示例

相关资料

  1. https://www.lua.org/ lua官网
  2. https://luaj.sourceforge.net luaj官网

Java&Lua工具集合

除了基本接口以外,我提供了Lua和Java之间的一些转换工具类
用于需要互相调用,或者同一份代码,需要两边语言都写的情况

此工具类基于class.lua(src/test/lua/lib/class.lua)的面向对象模式

一、Java调用库

Lua中需要调用java方法的地方,需要java创建调用类,然后在lua中调用,创建对应的lua类,能在lua 代码中更方便的调用。

这个过程可以通过工具生成lua文件,并加到Lua统一调用接口ServerLib.lua中 Lua中所有调用Java方法的接口都通过ServerLib调用,如SERVER_LIB.battle:invokeBattleResult(
BATTLE_ROOM:GetBattleId(), unit_player:GetPlayerId(), self.battleResult:GetId())

使用方法:

1. 新建Java调用库,增加类注解@LuaServerLib

参数名 描述 默认值
fieldName ServerLib字段名 Java类名首字母小写
className lua类名 Java类名
fileDir lua文件目录,以luaRootPath为根路径开始 工具类同目录下:~/src/test/java/lua/
comment 注释
addToServerLib 是否添加到ServerLib

2. 对需要调用的静态方法增加方法注解@LuaServerLibFunc

参数名 描述 默认值
comment 注释
returnComment 返回注释

3. 对方法内的参数增加参数注解@LuaParam

参数名 描述 默认值
comment 注释
value lua字段名

因为luaj编译后的class的字段名都变成arg0,arg1了,所以不加@LuaParam注解生成的参数名都不认识

4. 运行工具类:lua.LuaServerLibFileConverter,增加VM参数指定lua路径

-DluaRootPath=lua项目根路径
-DtemplateFilePath=模板文件路径(默认取框架自带模板)
-DjavaScanPackage=要扫描的Java包路径(默认com.hjc)
-DserverLibFilePath=ServerLib文件路径(默认Lib\\Server)

5. 刷新IDEA:File -> Reload All from Disk

参考代码:


/**
 * lua战斗核心调用Java类
 * <p>
 * 这个类里的接口和ServerLuaBattle.lua映射
 * </p>
 *
 * @author hejincheng
 * @version 1.0
 * @date 2022/2/16 18:55
 **/
@LuaServerLib(fieldName = "battle", className = "ServerLuaBattle", fileDir = "Lib/Server/")
public class LuaBattleFunction {

    /**
     * Lua脚本调用发送消息
     *
     * @param uid      玩家id
     * @param header   消息号
     * @param luaTable 推送参数
     */
    @LuaServerLibFunc(comment = "Lua脚本调用发送消息")
    public static void invokeSendMessageByLua(@LuaParam(value = "uid", comment = "玩家id") int uid,
                                              @LuaParam(value = "header", comment = "消息号") int header,
                                              @LuaParam(value = "luaTable", comment = "消息体") LuaTable luaTable) {
        IHumanService humanService = GameServiceManager.getService(IHumanService.class);
        if (humanService == null) {
            Log.battleLogger.error(String.format("%d.LuaBattle.invokeSendMessageByLua.server.err - header[%d].luaTable[%s]", uid, header, luaTable));
            return;
        }
        Human human = humanService.getHuman(uid);
        if (human == null) {
            Log.battleLogger.error(String.format("%d.LuaBattle.invokeSendMessageByLua.err.player.null - header[%d].luaTable[%s]", uid, header, luaTable));
            return;
        }
        human.push(header, luaTable);
    }
}

生成lua文件:

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Administrator.
--- DateTime: 2022-08-22 22:57:29
---
--- 通过Java工具类自动生成,请勿修改,重新生成会被覆盖
---

require "Lib/class"

---@class ServerLuaBattle : table
ServerLuaBattle = class(nil, 'ServerLuaBattle');

function ServerLuaBattle:ctor()
end

-- 获取战斗核心
---@param battleId number 战斗id
---@type function
---@return any
---@public
function ServerLuaBattle:getFightCoreLua(battleId)
    return
end

-- Lua脚本调用发送消息
---@param uid number 玩家id
---@param header number 消息号
---@param luaTable table 消息体
---@type function
---@return void
---@public
function ServerLuaBattle:invokeSendMessageByLua(uid, header, luaTable)
    return
end

-- Lua脚本调用广播消息
---@param raidId number 副本id
---@param header number 消息号
---@param luaTable table 推送参数
---@param includeServer boolean 广播服务端逻辑核
---@type function
---@return void
---@public
function ServerLuaBattle:invokeBroadcastMessageByLua(raidId, header, luaTable, includeServer)
    return
end

return ServerLuaBattle;

生成ServerLib.lua文件:

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by Administrator.
--- DateTime: 2022-08-22 22:57:30
---
--- 通过Java工具类自动生成,请勿修改,重新生成会被覆盖
---
--- 服务端Java调用库

require "Lib/class"

---@class ServerLib : table
---@field battle ServerLuaBattle
---@field logTool ServerLogTool
ServerLib = class(nil, 'ServerLib');

function ServerLib:ctor()
    self.battle = luajava.bindClass("com.hjc.demo.convert.lib.LuaBattleFunction")
    self.logTool = luajava.bindClass("com.hjc.lua.log.LuaLogTool")
end

二、Java数据类

有的数据,需要服务端全局共享,不能每场战斗都独一份lua数据,这种情况下可以在Java创建共享数据,这样的Model类可以通过工具生成需要的lua文件。

使用方法:

1. 新建Java调用库,增加类注解@LuaServerModel

参数名 描述 默认值
className lua类名 Java类名
fileDir lua文件目录,以~/为根路径开始 工具类同目录下:~/src/test/java/lua/
comment 注释

2. 运行工具类:lua.LuaServerModelFileConverter,增加VM参数指定lua路径

-DluaRootPath=lua项目根路径
-DtemplateFilePath=模板文件路径(默认取框架自带模板)
-DjavaScanPackage=要扫描的Java包路径(默认com.hjc)

3. 刷新IDEA:File -> Reload All from Disk

参考代码: FallDictData.java

package com.hjc.helper;

import com.hjc.annotation.LuaParam;
import com.hjc.annotation.LuaServerModel;
import lombok.Data;

/**
 * @author hejincheng
 * @version 1.0
 * @date 2022/3/7 17:19
 **/
@LuaServerModel(className = "FallDictData", comment = "掉落表数据", fileDir = "Battle/Logic/Room/BattleObject/Fall")
@Data
public class FallDictData {

    @LuaParam(comment = "掉落条件")
    private int conditionType;

    @LuaParam(comment = "掉落条件参数")
    private float conditionParam;

    @LuaParam(comment = "生效次数")
    private int activeTimes;

    @LuaParam(comment = "掉落id")
    private int fallObjectId;

    @LuaParam(comment = "掉落数量")
    private int fallCount;

    @LuaParam(comment = "冷却时间")
    private float cdLimitTime;

}

生成的lua:

--- 掉落表数据

require "Lib/class"

---@class FallDictData : table
---@field conditionType number 掉落条件
---@field conditionParam number 掉落条件参数
---@field activeTimes number 生效次数
---@field fallObjectId number 掉落id
---@field fallCount number 掉落数量
---@field cdLimitTime number 冷却时间
FallDictData = class(nil, 'FallDictData');

function FallDictData:ctor(_conditionType, _conditionParam, _activeTimes, _fallObjectId, _fallCount, _cdLimitTime)
    self.conditionType = _conditionType
    self.conditionParam = _conditionParam
    self.activeTimes = _activeTimes
    self.fallObjectId = _fallObjectId
    self.fallCount = _fallCount
    self.cdLimitTime = _cdLimitTime
end

return FallDictData;

目前该框架底层基于第三方开源库Luaj实现,然后它是用起来最顺手的,但它也存在很多致命的缺点

Luaj踩坑指南

1. 不要创建多份Globals对象

如果你想每一场战斗,都启动不同的Lua环境,那我劝你最好放弃这个想法。因为每启动一个luaj的Globals,load一次lua工程,他就会把lua工程编译一次,编译的过程中,除了ClassLoader的load中本身对meta空间的占用外,luaj还会对转换过来的字节码byte[]进行缓存,最后当你启动上百上千场战斗的时候,你会发现你的meta空间和堆空间根本不够用(如果你的机器能支撑你启动这么多Globals)
在我的开源框架中,我已经把Globals的创建和初始化放到了全局Manager中,使用的时候只需要考虑你要调用的lua方法是什么即可。

2.尽量少的进行数学运算

我知道,这当然是不可能的,战斗逻辑怎么着也得进行数学运算的。然而事实是,luaj会对每一个加减乘除进行装箱拆箱,假如你有一个公式是这样的

m=a + b * c / d-e

那么要计算这个m,它会new出来4个LuaDouble或LuaInteger对象。对象少量的计算,这还是可接收的,可一旦涉及到大量的数学运算,这段lua代码编译出来Java代码就会new出一大堆的临时对象,对于JVM来说,年轻代的GC压力就会变得很大。

3.尽量使用lua 5.2语法

这点其实还好,只要统一了语法,基本就没什么太大问题,偶尔有一两个新特性,我们甚至可以通过自定义的构建Globals来注入一些我们自定义的方法。

后续计划

鉴于目前所遇到的困难,后续打算把我开源框架的底层luaj进行优化或者替换。
目前对于数学运算,还是有办法解决的,比如通过对所有的公式进行集中的提取,然后用java代码继承VaragsFunction来进行Java版本的实现,并覆盖原来编译的lua代码,后续也可以考虑在我的框架中,提供代码注入的接口。