活动系统是每个游戏都有的子系统,内容的不固定性和不同游戏基础系统的表现方式也给游戏带入一些新鲜的元素,王者荣耀的的一个皮肤活动都能收入一个亿,这样的游戏太可怕了,
运营活动的设计目的是:提升游戏的充值付费、留存、玩家活跃、在线时长、LTV等指标。
虽然王者荣耀的代码看不到,但是今天我带大家一起复刻一个王者荣耀的活动系统,坐好了,准备发车,走起
1、活动类型
活动也是拉营收的最主要的方式和手段,这也是运营同学的主要工作,运营活动最常见的莫过下面这些:
1、充值活动,比如首充活动,充值送道具等等活动
2、转盘抽奖活动,比如收集碎片进行抽奖,或者买道具进行抽奖;
3、开服活动;七日登陆活动,开服
4、回归活动;邀请老玩家回归
5、冲级活动,达到多少级可以领取礼包,礼盒。
6、商城打折、限时、团购促销活动;
7、每日及累计签到活动;
8、BOSS活动;世界boss活动,公会boss活动
9、比赛活动;比拼厨技等
10、在线奖励及BUFF活动;
11、公会活动,之前玩过的蜀门有公会开树增加经验活动
12、答题活动,火影忍者手游的答题活动
13、分享活动;分享到朋友圈拿奖励
2、需求
从第一部分可以看到活动的需求还是多种多样的,活动系统最主要的需求
1.可以动态的调整线上的活动
2.可以根据配置的时间进行开启,关闭,领奖。
3.方便配置,选择json格式配置,前公司用的是xml ,很烦,当时还有层级的限制。
需求有了,现在开始制定方案,也不绕弯子了,直接阐明我们当前使用的技术方案。
1.运营配置活动,并且发布到 web 服务器
2.运营调用web 命令,通知各个服务器进行活动更新,读取新的活动
3.游戏服务器下载打包的活动数据到本地
4.读取活动的数据
5.加载进内存
3、文件下载
http下载java有几种常用的方式,
一种是使用httpClient ,
另一种则是通过HttpURLConnection去实现,HttpURLConnection是JAVA的标准类,是JAVA原生的一种实现方式。
还有就是我选择使用Okhttp,我选择的原因就是不想用httpclient ,就这么简单,任性。
pom.xml 中加入以下依赖:
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.8.0</version>
</dependency>
web 服务器我选择了一个小巧的服务器NetBox2.exe ,具体的说明如下,想要测试这个服务器,可以直接手写一个hello world 的index.html 和NetBox2.exe 放在一起,然后直接使用http://localhost:49983(端口可以右键任务栏netbox 查看,默认是80端口) ,默认会访问index.html.
优点:这个服务器不需要安装,直接运行即可,同时比较小巧,只有不到636k,携带方便。不需要做任何的配置,直接使用,是测试的时候不错的选择,在线上的时候可以再切换到tomcat或者Nginx 等服务器,想要这个服务器的可以关注我公众号【香菜聊游戏】,回复NetBox 就可以了。
使用了异步下载的机制,这样不至于卡掉线程,将进度进行回调。
具体的测试代码如下:
package com.ploy;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* 文件下载工具类(单例模式)(有借鉴别人代码,找不到地址了,可以联系我)
*
* @author 香菜
*/
@Slf4j
public class DownloadUtil {
public static OkHttpClient okHttpClient;
/**
* 初始化 httpClient
*
* @return
*/
public static synchronized OkHttpClient getHttpClient() {
if (okHttpClient == null) {
okHttpClient = new OkHttpClient();
}
return okHttpClient;
}
public interface OnDownloadListener {
/**
* 下载成功之后的文件
*/
void onDownloadSuccess(File file);
/**
* 下载进度
*/
void onDownloading(int progress);
/**
* 失败信息
*/
void onDownloadFailed(Exception e);
}
/**
* @param url 下载连接
* @param destFileDir 下载的文件储存目录
* @param destFileName 下载文件名称,后面记得拼接后缀,否则手机没法识别文件类型
* @param listener 下载监听
*/
public static void downloadByAsync(final String url, final String destFileDir, final String destFileName, final OnDownloadListener listener) {
Request request = new Request.Builder()
.url(url)
.build();
//异步请求
getHttpClient().newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// 下载失败监听回调
listener.onDownloadFailed(e);
}
@Override
public void onResponse(Call call, Response response) {
if (response.body() == null) {
listener.onDownloadFailed(new Exception(" body is null"));
return;
}
byte[] buf = new byte[4096];
int len = 0;
// 储存下载文件的目录
File dir = new File(destFileDir);
if (!dir.exists()) {
dir.mkdirs();
}
File file = new File(dir, destFileName);
try (InputStream is = response.body().byteStream();
FileOutputStream fos = new FileOutputStream(file)) {
int size = 0;
long total = response.body().contentLength();
while ((size = is.read(buf)) != -1) {
len += size;
fos.write(buf, 0, size);
int process = (int) Math.floor(((double) len / total) * 100);
// 控制台打印文件下载的百分比情况
listener.onDownloading(process);
}
fos.flush();
// 下载完成
listener.onDownloadSuccess(file);
} catch (Exception e) {
log.error("error:{}", e);
listener.onDownloadFailed(e);
}
}
});
}
public static void main(String[] args) {
//异步下载
DownloadUtil.downloadByAsync("http://localhost/aaa.zip",
"E:\\video\\Learn\\Learn\\src\\main\\java\\com\\ploy\\", "abc.zip", new DownloadUtil.OnDownloadListener() {
@Override
public void onDownloadSuccess(File file) {
log.info("下载完成");
}
@Override
public void onDownloading(int progress) {
log.info("下载进行中" + progress);
}
@Override
public void onDownloadFailed(Exception e) {
//下载异常进行相关提示操作
log.error("下载出错", e);
}
});
}
4、文件解压
文件下载回来之后,需要解压,解压选择了jdk 自带的解压方式,具体的用法都在代码里,也没什么难的。
知识点 :最主要是文件夹的创建 pathFile.mkdirs();
还有zipFile 的使用
package com.ploy;
import java.io.*;
import java.nio.charset.Charset;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* 解压工具类
* @author 香菜
*/
public class UnzipUtil {
/**
* 解压文件到指定目录
*/
@SuppressWarnings("rawtypes")
public static void unZipFiles(File zipFile, String descDir) throws IOException {
File pathFile = new File(descDir);
if (!pathFile.exists()) {
// 如果路径上文件夹不存在,则创建
pathFile.mkdirs();
}
//解决zip文件中有中文目录或者中文文件
ZipFile zip = new ZipFile(zipFile, Charset.forName("GBK"));
byte[] buf1 = new byte[1024];
for (Enumeration entries = zip.entries(); entries.hasMoreElements(); ) {
ZipEntry entry = (ZipEntry) entries.nextElement();
String zipEntryName = entry.getName();
try (InputStream in = zip.getInputStream(entry)) {
String outPath = (descDir + zipEntryName).replaceAll("\\*", "/");
//判断路径是否存在,不存在则创建文件路径
File file = new File(outPath.substring(0, outPath.lastIndexOf('/')));
if (!file.exists()) {
file.mkdirs();
}
//判断文件全路径是否为文件夹,如果是上面已经上传,不需要解压
if (new File(outPath).isDirectory()) {
continue;
}
//输出文件路径信息
System.out.println(outPath);
try (OutputStream out = new FileOutputStream(outPath)) {
int len;
while ((len = in.read(buf1)) > 0) {
out.write(buf1, 0, len);
}
}
}
}
System.out.println("******************解压完毕********************");
}
public static void main(String[] args) throws IOException {
/**
* 解压文件
*/
File zipFile = new File("E:\\video\\Learn\\Learn\\src\\main\\java\\com\\ploy\\abc.zip");
String path = "E:/video/Learn/Learn/src/main/java/com/ploy/";
unZipFiles(zipFile, path);
}
}
5、json的读取
json的读取使用了fastjson 的库,使用简单,同时也配置比较方便,解析也比较方便。
知识点:文件读取,fastjson 的使用
package com.ploy;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
/**
* @author 香菜
*/
public class FileUtil {
public static String readFile(String filePath) throws IOException {
FileReader fileReader = new FileReader(filePath);
BufferedReader bReader = new BufferedReader(fileReader);//new一个BufferedReader对象,将文件内容读取到缓存
StringBuilder sb = new StringBuilder();//定义一个字符串缓存,将字符串存放缓存中
String s = "";
while ((s = bReader.readLine()) != null) {//逐行读取文件内容,不读取换行符和末尾的空格
sb.append(s);//将读取的字符串添加换行符后累加存放在缓存中
System.out.println(s);
}
bReader.close();
String jsonStr = sb.toString();
System.out.println(jsonStr);
return jsonStr;
}
}
6、模块组织方式
压缩包的截图
ployMenu.json说明:ployMenu.json 是所有活动的菜单,具体的菜单是整个所有的活动
各字段说明:
pid : 活动id,是关联活动详情的id
type:是活动的类型
begin: 活动的开始时间
end :活动的结束时间
draw : 是活动可以领奖的时间,一般要大于等于活动结束时间
[
{
"begin": 2,
"draw": 4,
"end": 3,
"pid": 6,
"type": 6
}
]
6.json 说明:
6.json 是活动id 为6 的具体的活动信息,每个活动类型的不同,可以自定义,只要是json格式就行
{"name":"香菜","s":18}
7、代码展示
活动对象定义:
package com.ploy;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class PloyVO {
//活动ID
private int pid;
//活动开始时间秒
private int begin;
//活动结束时间秒
private int end;
//活动可领奖秒
private int draw;
//活动类型
private int type;
//活动明细对象
private Object detail;
}
活动枚举定义:
package com.ploy;
import com.ploy.detail.TestDetailVO;
public enum PloyEnum {
SEVEN_LOGIN(6, TestDetailVO.class)
;
private int ployType;
private Class detailClass;
PloyEnum(int ployType, Class detailClass) {
this.ployType = ployType;
this.detailClass = detailClass;
}
public int getPloyType() {
return ployType;
}
public Class getDetailClass() {
return detailClass;
}
public static PloyEnum getPloyType(int type) {
for (PloyEnum pt : values()) {
if (pt.getPloyType() == type) {
return pt;
}
}
return null;
}
}
示例活动详细详情:
package com.ploy.detail;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
/**
* 示例活动详细详情
*/
public class TestDetailVO {
private String name;
private int s;
}
活动工具类:
package com.ploy;
import com.alibaba.fastjson.JSON;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.IOException;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 活动工具类
* @author 香菜
*/
@Slf4j
public class PloyUtil {
//活动缓存 KEY:活动ID VALUE:活动对象
private static Map<Integer, PloyVO> ployMap = Maps.newConcurrentMap();
public static void main(String[] args) {
reloadPloy();
}
public static void reloadPloy() {
Map<Integer, PloyVO> tmpPloyMap = Maps.newConcurrentMap();
// 下载文件
//异步下载
DownloadUtil.downloadByAsync("http://localhost/ployPkg.zip",
"E:/video/Learn/Learn/src/main/java/com/ploy/unzip", "ployPkg.zip", new DownloadUtil.OnDownloadListener() {
@Override
public void onDownloadSuccess(File file) {
// 解压文件
try {
String foldPath = "E:\\video\\Learn\\Learn\\src\\main\\java\\com\\ploy\\unzip\\";
UnzipUtil.unZipFiles(file, foldPath);
String jsonStr = FileUtil.readFile(foldPath + "/ployPkg/ployMenu.json");
List<PloyVO> ployList = JSON.parseArray(jsonStr, PloyVO.class);
for (PloyVO ployVO : ployList) {
// 解析配置文件
parsePloy(ployVO, foldPath + "/ployPkg");
tmpPloyMap.put(ployVO.getPid(), ployVO);
}
ployMap = tmpPloyMap;
System.out.println("load finish");
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void onDownloading(int progress) {
log.info("下载进行中" + progress);
}
@Override
public void onDownloadFailed(Exception e) {
//下载异常进行相关提示操作
log.error("下载出错", e);
}
});
}
private static void parsePloy(PloyVO ployVO, String folderPath) throws IOException {
int type = ployVO.getType();
String s = FileUtil.readFile(folderPath + "/" + ployVO.getPid() + ".json");
PloyEnum ployType = PloyEnum.getPloyType(type);
Object o = JSON.parseObject(s, ployType.getDetailClass());
ployVO.setDetail(o);
}
/**
* 获取活动对象
*
* @param pid
* @return
*/
public static PloyVO getPloy(int pid) {
PloyVO pv = ployMap.get(pid);
if (pv == null) {
log.error("not found ploy pid is " + pid);
}
return pv;
}
/**
* 检测是否在活动时间范围内
*
* @param pv
* @param nowSec
* @return
*/
public static boolean checkInPloyTime(PloyVO pv, int nowSec) {
return pv.getBegin() <= nowSec && (pv.getEnd() == 0 || nowSec <= pv.getEnd());
}
/**
* 根据活动类型获得活动信息(限同一时刻只能出现一个该类型的活动)
*
* @param ployEnum
* @return
*/
public static List<PloyVO> getPloyByType(PloyEnum ployEnum) {
List<PloyVO> retList = Lists.newArrayList();
int nowSec = (int) (new Date().getTime() / 1000L);
for (PloyVO pvo : ployMap.values()) {
if (pvo.getType() == ployEnum.getPloyType() && checkInPloyTime(pvo, nowSec)) {
retList.add(pvo);
}
}
return retList;
}
}
活动重新加载的入口是reloadPloy(),在需要重新加载活动数据的时候直接调用reload,
注意:新活动先加载到内存,然后再覆盖ployMap
运行ployUtil,可以看到数据已经加载到内存:
8、还有哪些优化点
1、对活动数据进行加密,签名,防止不法之徒获取运营数据
2、ployUtil只提供了一些几个简单的结构,可以根据需求增加一些新的接口,比如根据活动类型获取数据,或者当前所有的开启的活动等等接口,方便在使用的时候调用
3、和客户端通信,在玩家登陆的时候可以把活动的数据发给客户端,这样数据和服务器保持一致,每个活动自己通信就可以了。客户端可以根据活动的时间判断,或者开启活动,或者去除活动的icon.
4、代码只是展示了思路,但是还有些细节没有处理,比如异常的处理,在项目中使用的时候可以根据项目的内容进行调整
5、可以将程序中的一些路径等等当做配置,而不是写死在代码里
9、总结
知识点:
OkHttp 的使用,异步下载文件到本地,DownloadUtil
解压zip文件的方式,方法,平常比较少用的工具类,ZipUtil
读取文件到字符串,Java IO 的使用 FileUtil
fastJson 的使用,将字符串转为List,
活动的设计模式,对每个活动的单独读取的使用方式
活动流程:
运营策划活动
运营配置活动并打包放到web服务器上
通知游戏服加载新活动
游戏服 下载活动到本地
解压活动压缩包
读取ployMenu.json,生成ployList
根据ployVO 具体生成 活动细节
项目源码下载地址:https://download.csdn.net/download/perfect2011/19863331
【奔跑吧!JAVA】有奖征文火热进行中:https://bbs.huaweicloud.com/blogs/265241
【奔跑吧!JAVA】有奖征文火热进行中:https://bbs.huaweicloud.com/blogs/265241