閑聊
游戲服務(wù)器
現(xiàn)在游戲服務(wù)器已經(jīng)非常普遍了,在游戲行業(yè)早期倾鲫,服務(wù)器大部分都還是C或者C++粗合,以追求更高的執(zhí)行效率萍嬉。而那個時候的Java,還被認為只能處理Web開發(fā)這樣的對延時要求稍低的應(yīng)用隙疚。
誰知道幾年后壤追,編程語言遍地開花,別說Java了供屉,Go行冰,Python,NodeJs(JavaScript)伶丐,甚至PHP都能作為游戲服務(wù)器了悼做。老大哥C++雖然執(zhí)行效率最高,但開發(fā)效率卻很低哗魂,更不用說維護成本和人才成本肛走。而近幾年新起的Go語言算是新起之秀,憑借它語言級的并發(fā)能力獲得一票支持录别。
相對來說朽色,Java算是穩(wěn)步發(fā)展,在十幾年的版本迭代升級中庶灿,把自己的IO纵搁,多線程吃衅,內(nèi)存往踢,GC,效率等一系列都進行全面升級徘层,如今也算是在服務(wù)器這塊站穩(wěn)了腳本峻呕,相對來說在市場上的相關(guān)從業(yè)人員也是最多的。得益于Java完善的開源社區(qū)的發(fā)展趣效,多到數(shù)不清的第三方開源框架也讓Java的生態(tài)變得非常完善了瘦癌。
所以如果現(xiàn)在要開發(fā)一個游戲服務(wù)器,從開發(fā)效率跷敬,人才成本讯私,維護成本和性能等綜合考慮來看,Java再適合不過了西傀。
游戲客戶端
回過頭來看斤寇,游戲的客戶端也一樣經(jīng)歷了一系列變化。最早的PC的游戲客戶端拥褂,可能大部分也都是C或者C++ 進行開發(fā)的娘锁,后來進入Web時代之后,出現(xiàn)了用Flash開發(fā)的頁游饺鹃。后來莫秆,大佬們發(fā)現(xiàn)间雀,大部分的游戲,都是那些東西镊屎,圖形惹挟,動畫,特效缝驳,物理等等匪煌,于是出現(xiàn)了游戲引擎。一開始的游戲引擎可能也都是公司自用党巾。到現(xiàn)在萎庭,大家也基本都知道了,出現(xiàn)了Egret齿拂、Laya驳规、Cocos、Unity署海、Unreal等游戲引擎吗购,它們也分別支持著不同的語言,有JavaScript砸狞、C#捻勉、C++等。
以上這么一大段刀森,其實也只是想說一下踱启,不管是前端還是后端,都有著很多種的語言研底。那能不能讓前后端使用同一種語言呢埠偿?這樣一來,我們寫一套游戲的邏輯代碼榜晦,不就能既跑在客戶端冠蒋,又跑在服務(wù)器了嗎,而不用分別在兩種語言上分別實現(xiàn)了嗎乾胶?
事實上抖剿,這也并不是不可行的,比如C++识窿,C#斩郎,JavaScript這幾種語言,就既能開發(fā)服務(wù)器腕扶,又能開發(fā)客戶端孽拷。然而實際上,在組建團隊的時候半抱,想要找到剛好前后端技術(shù)棧都匹配的技術(shù)人員脓恕,也并非易事膜宋。
Java&Lua方案
我目前的項目,其實就面臨這樣一個問題炼幔,想要讓服務(wù)器和客戶端使用同一個語言開發(fā)同一套游戲戰(zhàn)斗邏輯層代碼:當我們想讓游戲以單機運行的時候秋茫,這套邏輯層代碼可以完全跑在客戶端;而當我們想讓游戲以聯(lián)機運行的時候乃秀,我們就讓這套戰(zhàn)斗邏輯代碼跑在服務(wù)器肛著,僅對客戶端作狀態(tài)同步的表現(xiàn)《逖叮可是我們服務(wù)器是Java枢贿,客戶端是Unity,怎么辦呢刀脏?我們用了一個中間語言——Lua局荚,來作為邏輯層的代碼開發(fā)。Unity中使用Lua還算比較容易愈污,因為它本身是支持啟動Lua虛擬機的耀态。但是要在Java中調(diào)用Lua,就稍微有點頭疼了暂雹。
搜索了網(wǎng)上的解決方案首装,方案不是很多,其中Luaj算是使用起來最順手杭跪,運行效率也相對較高的方案仙逻,不過Luaj使用起來也有很多坑(后面細聊)。
通過Luaj的官網(wǎng)或者github可以詳細了解luaj的相關(guān)API和實現(xiàn)細節(jié)揍魂。
Lua共戰(zhàn)邏輯框架
經(jīng)過研究和討論后桨醋,我們最終得到的前后端的共戰(zhàn)框架開發(fā)模型是這樣的
在實際運行中棚瘟,我們會分別在服務(wù)端和客戶端啟動一套戰(zhàn)斗邏輯Lua代碼现斋,我們可以通過配置分別以兩種模型啟動戰(zhàn)斗:
-
單機模型
Client Lua:運行完整戰(zhàn)斗邏輯
這種模式相對比較容易理解,即在客戶端運行完整的戰(zhàn)斗邏輯
-
聯(lián)機模型
Client Lua:運行部分戰(zhàn)斗邏輯偎蘸,對服務(wù)器的狀態(tài)同步消息進行本地邏輯校正
Server Lua:運行完整戰(zhàn)斗邏輯庄蹋,并進行狀態(tài)同步廣播
這種模式即狀態(tài)同步模式,主要邏輯都在服務(wù)端運行迷雪,并且通過網(wǎng)絡(luò)通信把運行后的狀態(tài)同步到客戶端限书,而客戶端也可以通過同樣的Lua邏輯代碼進行邏輯的預(yù)演算,待收到服務(wù)端同步過來的狀態(tài)后章咧,再對預(yù)演算的結(jié)果進行校正
Java-Lua 戰(zhàn)斗開源框架
基于這套框架倦西,我對現(xiàn)有的Java&Lua戰(zhàn)斗框架做了獨立于業(yè)務(wù)的抽象,并做了開源赁严。
地址:https://github.com/hjcenry/lua-java-battle
基于luaj實現(xiàn)的java使用lua的戰(zhàn)斗框架
該框架基于luaj的二次封裝實現(xiàn)(https://luaj.sourceforge.net)
提供功能
- luaj基礎(chǔ)接口的調(diào)用封裝
- 簡化luaj環(huán)境搭建步驟
- 管理lua戰(zhàn)斗并提供接口
- lua面向?qū)ο笫褂梅桨?class.lua)
- lua戰(zhàn)斗框架使用示例
- luaj踩坑指南
該框架提供Java-Lua的戰(zhàn)斗框架扰柠,有以下優(yōu)缺點
- 優(yōu)點:
- 公用邏輯lua代碼:前后端可以基于同一套語言使用同一套戰(zhàn)斗邏輯代碼粉铐,只需要設(shè)計好共戰(zhàn)框架,即可實現(xiàn)一份代碼兩處使用卤档。前后端程序員也可以基于這套框架共同開發(fā)蝙泼,這無論是對于狀態(tài)同步還是幀同步來說,都可以一定程度提升開發(fā)效率劝枣。
- luaj框架相比其他java調(diào)用lua方式汤踏,是目前為止效率最高的。
- lua代碼無需編譯即可直接使用舔腾,可以通過luaj設(shè)計一套熱更邏輯
- 缺點(踩坑指南):
- 占用jvm更多的堆和meta空間
luaj提供兩種編譯器溪胶,luac和luajc。其中l(wèi)uac在load文件之后稳诚,會創(chuàng)建一個LuaClosure對象载荔,其運行過程中會逐行解析lua命令,當然其運行效率也不會太高。
而luajc的原理是通過編譯成Java字節(jié)碼雪隧,并通過它的JavaLoader(繼承ClassLoader)加載到內(nèi)存蕾殴,相當于一次編譯多次運行。
但是熟悉的Java類加載機制的朋友應(yīng)該清楚工扎,這個過程中,JVM會在meta空間創(chuàng)建Klass信息衔蹲,并在ClassLoader保存Klass引用肢娘。與此同時,luaj的JavaLoader也做了一件事:緩存動態(tài)生成的字節(jié)碼byte[]
這種情況下舆驶,啟動一個lua環(huán)境則會增加JVM的堆和meta空間的占用橱健。
- 運行效率不如原生Java
luaj的作者描述luaj的運行效率基本和原生lua虛擬機運行效率相當,甚至反超沙廉。但在我的實際測試中拘荡,我沒有拿luaj和原生lua對比,而是拿luaj和原生java相比撬陵,其性能是遠不如原生Java的珊皿。
通過觀察luaj編譯后的源碼也能發(fā)現(xiàn),拿i++這樣一個操作來舉例巨税,原本在Java中蟋定,是可以直接對基本數(shù)據(jù)類型int進行操作的,而在luaj中草添,會對int進行類似Integer的包裝類(LuaInteger)進行包裝驶兜。
- 既是優(yōu)點也是缺點:
- 靈活的lua代碼
靈活是一把雙刃劍,用好了可以大幅提升開發(fā)效率,而用的不好的話抄淑,不僅不能提升開發(fā)效率犀盟,還可能對開發(fā)和維護,都帶來極大的痛苦蝇狼,這非吃某耄考驗底層開發(fā)的能力。
綜上所述:是否使用lua作為Java服務(wù)器的戰(zhàn)斗邏輯代碼迅耘,需要根據(jù)實際情況而定贱枣,它的優(yōu)點是否給你代碼巨大好處,同時你也能忍受它的缺點或者有其他方案克服它的缺點颤专。
歡迎大家使用纽哥,有任何bug以及優(yōu)化需求,歡迎提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參數(shù)
LuaInit.LuaInitBuilder luaInit=LuaInit.builder();
luaInit.preScript("print('Hello Lua Battle!!!')");
// 設(shè)置lua根路徑
luaInit.luaRootPath("F:\\project\\lua-java-battle\\src\\main\\lua\\");
// 加載lua調(diào)用接口目錄
luaInit.luaLoadDirectories("interface");
// 加載lua主文件
luaInit.luaLoadFiles("FightManager.lua");
// 展示log
luaInit.showLog(true);
2. 初始化Lua環(huán)境
LuaBattleManager.getInstance().init(luaInit.build());
3. 初始化并緩存Java調(diào)用的Lua方法
// 初始所有需要用到的方法
this.xxxFunction=this.initFunction("XXX.xxx");
this.xxxFunction2=this.initFunction("xxx");
4. 調(diào)用Lua方法
this.xxxFunction.invoke();
this.xxxFunction2.invoke(LuaNumber.valueOf(123));
以上是簡單的示例這個框架應(yīng)該如何使用栖秕,BattleDemoService中提供了一套比較完成Lua戰(zhàn)斗框架的示例
使用方法以及例子
相關(guān)資料
- https://www.lua.org/ lua官網(wǎng)
- https://luaj.sourceforge.net luaj官網(wǎng)
Java&Lua工具集合
除了基本接口以外春塌,我提供了Lua和Java之間的一些轉(zhuǎn)換工具類
用于需要互相調(diào)用,或者同一份代碼簇捍,需要兩邊語言都寫的情況
此工具類基于class.lua(src/test/lua/lib/class.lua)的面向?qū)ο竽J?/p>
一只壳、Java調(diào)用庫
Lua中需要調(diào)用java方法的地方,需要java創(chuàng)建調(diào)用類暑塑,然后在lua中調(diào)用吼句,創(chuàng)建對應(yīng)的lua類,能在lua 代碼中更方便的調(diào)用事格。
這個過程可以通過工具生成lua文件惕艳,并加到Lua統(tǒng)一調(diào)用接口ServerLib.lua中 Lua中所有調(diào)用Java方法的接口都通過ServerLib調(diào)用,如SERVER_LIB.battle:invokeBattleResult(
BATTLE_ROOM:GetBattleId(), unit_player:GetPlayerId(), self.battleResult:GetId())
使用方法:
1. 新建Java調(diào)用庫驹愚,增加類注解@LuaServerLib
參數(shù)名 | 描述 | 默認值 |
---|---|---|
fieldName | ServerLib字段名 | Java類名首字母小寫 |
className | lua類名 | Java類名 |
fileDir | lua文件目錄远搪,以luaRootPath為根路徑開始 | 工具類同目錄下:~/src/test/java/lua/ |
comment | 注釋 | 無 |
addToServerLib | 是否添加到ServerLib | 是 |
2. 對需要調(diào)用的靜態(tài)方法增加方法注解@LuaServerLibFunc
參數(shù)名 | 描述 | 默認值 |
---|---|---|
comment | 注釋 | 無 |
returnComment | 返回注釋 | 無 |
3. 對方法內(nèi)的參數(shù)增加參數(shù)注解@LuaParam
參數(shù)名 | 描述 | 默認值 |
---|---|---|
comment | 注釋 | 無 |
value | lua字段名 | 無 |
因為luaj編譯后的class的字段名都變成arg0,arg1了,所以不加@LuaParam注解生成的參數(shù)名都不認識
4. 運行工具類:lua.LuaServerLibFileConverter逢捺,增加VM參數(shù)指定lua路徑
-DluaRootPath=lua項目根路徑
-DtemplateFilePath=模板文件路徑(默認取框架自帶模板)
-DjavaScanPackage=要掃描的Java包路徑(默認com.hjc)
-DserverLibFilePath=ServerLib文件路徑(默認Lib\\Server)
5. 刷新IDEA:File -> Reload All from Disk
參考代碼:
/**
* lua戰(zhàn)斗核心調(diào)用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腳本調(diào)用發(fā)送消息
*
* @param uid 玩家id
* @param header 消息號
* @param luaTable 推送參數(shù)
*/
@LuaServerLibFunc(comment = "Lua腳本調(diào)用發(fā)送消息")
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
-- 獲取戰(zhàn)斗核心
---@param battleId number 戰(zhàn)斗id
---@type function
---@return any
---@public
function ServerLuaBattle:getFightCoreLua(battleId)
return
end
-- Lua腳本調(diào)用發(fā)送消息
---@param uid number 玩家id
---@param header number 消息號
---@param luaTable table 消息體
---@type function
---@return void
---@public
function ServerLuaBattle:invokeSendMessageByLua(uid, header, luaTable)
return
end
-- Lua腳本調(diào)用廣播消息
---@param raidId number 副本id
---@param header number 消息號
---@param luaTable table 推送參數(shù)
---@param includeServer boolean 廣播服務(wù)端邏輯核
---@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工具類自動生成蒸甜,請勿修改棠耕,重新生成會被覆蓋
---
--- 服務(wù)端Java調(diào)用庫
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數(shù)據(jù)類
有的數(shù)據(jù)柠新,需要服務(wù)端全局共享,不能每場戰(zhàn)斗都獨一份lua數(shù)據(jù)辉巡,這種情況下可以在Java創(chuàng)建共享數(shù)據(jù)恨憎,這樣的Model類可以通過工具生成需要的lua文件。
使用方法:
1. 新建Java調(diào)用庫,增加類注解@LuaServerModel
參數(shù)名 | 描述 | 默認值 |
---|---|---|
className | lua類名 | Java類名 |
fileDir | lua文件目錄憔恳,以~/為根路徑開始 | 工具類同目錄下:~/src/test/java/lua/ |
comment | 注釋 | 無 |
2. 運行工具類:lua.LuaServerModelFileConverter瓤荔,增加VM參數(shù)指定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 = "掉落表數(shù)據(jù)", fileDir = "Battle/Logic/Room/BattleObject/Fall")
@Data
public class FallDictData {
@LuaParam(comment = "掉落條件")
private int conditionType;
@LuaParam(comment = "掉落條件參數(shù)")
private float conditionParam;
@LuaParam(comment = "生效次數(shù)")
private int activeTimes;
@LuaParam(comment = "掉落id")
private int fallObjectId;
@LuaParam(comment = "掉落數(shù)量")
private int fallCount;
@LuaParam(comment = "冷卻時間")
private float cdLimitTime;
}
生成的lua:
--- 掉落表數(shù)據(jù)
require "Lib/class"
---@class FallDictData : table
---@field conditionType number 掉落條件
---@field conditionParam number 掉落條件參數(shù)
---@field activeTimes number 生效次數(shù)
---@field fallObjectId number 掉落id
---@field fallCount number 掉落數(shù)量
---@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實現(xiàn),然后它是用起來最順手的钥组,但它也存在很多致命的缺點
Luaj踩坑指南
1. 不要創(chuàng)建多份Globals對象
如果你想每一場戰(zhàn)斗输硝,都啟動不同的Lua環(huán)境,那我勸你最好放棄這個想法程梦。因為每啟動一個luaj的Globals点把,load一次lua工程,他就會把lua工程編譯一次屿附,編譯的過程中郎逃,除了ClassLoader的load中本身對meta空間的占用外,luaj還會對轉(zhuǎn)換過來的字節(jié)碼byte[]進行緩存挺份,最后當你啟動上百上千場戰(zhàn)斗的時候褒翰,你會發(fā)現(xiàn)你的meta空間和堆空間根本不夠用(如果你的機器能支撐你啟動這么多Globals)
在我的開源框架中,我已經(jīng)把Globals的創(chuàng)建和初始化放到了全局Manager中匀泊,使用的時候只需要考慮你要調(diào)用的lua方法是什么即可优训。
2.盡量少的進行數(shù)學運算
我知道,這當然是不可能的各聘,戰(zhàn)斗邏輯怎么著也得進行數(shù)學運算的型宙。然而事實是,luaj會對每一個加減乘除進行裝箱拆箱伦吠,假如你有一個公式是這樣的
m=a + b * c / d-e
那么要計算這個m妆兑,它會new出來4個LuaDouble或LuaInteger對象。對象少量的計算毛仪,這還是可接收的搁嗓,可一旦涉及到大量的數(shù)學運算,這段lua代碼編譯出來Java代碼就會new出一大堆的臨時對象箱靴,對于JVM來說腺逛,年輕代的GC壓力就會變得很大。
3.盡量使用lua 5.2語法
這點其實還好衡怀,只要統(tǒng)一了語法棍矛,基本就沒什么太大問題,偶爾有一兩個新特性抛杨,我們甚至可以通過自定義的構(gòu)建Globals來注入一些我們自定義的方法够委。
后續(xù)計劃
鑒于目前所遇到的困難,后續(xù)打算把我開源框架的底層luaj進行優(yōu)化或者替換怖现。
目前對于數(shù)學運算茁帽,還是有辦法解決的玉罐,比如通過對所有的公式進行集中的提取,然后用java代碼繼承VaragsFunction來進行Java版本的實現(xiàn)潘拨,并覆蓋原來編譯的lua代碼吊输,后續(xù)也可以考慮在我的框架中,提供代碼注入的接口铁追。