当前位置: 首页 > news >正文

游戏通用活动框架

1.业务背景

大部分游戏,一进入到主界面,就会看到各种各样的活动,例如开服活动,春节活动,五一活动,周六充值特惠等等。 

 游戏活动具有多种功能,比如:

  • 吸引新用户:通过举办具有吸引力的活动,如开服活动中的新手冲榜、首充礼包等,吸引新玩家下载和注册游戏。这些活动能让新用户感受到游戏的丰富内容和福利,降低他们进入游戏的门槛,提高游戏的拉新效果4。
  • 提高用户活跃度:日常周期性活动如周末双倍经验 / 掉落、限时抢购等,以及节日活动和主题活动等,能定期为玩家提供新鲜的游戏内容和目标。玩家为了参与活动获取奖励,会更频繁地登录游戏并投入时间进行游戏,从而增加游戏的在线时长和用户粘性4。
  • 增加用户留存率:例如七日签到、日常签到活动等,通过持续给予玩家奖励,培养玩家的登录习惯,让玩家逐渐对游戏产生依赖。当玩家在游戏中投入了一定的时间和精力,并且通过活动获得了各种资源和成就感后,他们更有可能长期留在游戏中4。
  • 促进用户付费:一些活动如首充礼包、限时折扣的付费道具等,会刺激玩家进行付费。玩家可能会因为活动的优惠和稀有奖励而愿意花费金钱购买游戏内的虚拟物品或服务,从而提高游戏的收入4。
  • 增强用户社交互动:竞技活动中的排位赛、跨服竞技比赛等,以及主题活动中的合作副本、团队任务等,都需要玩家与其他玩家进行合作或竞争。这促进了玩家之间的交流、互动和组队,形成游戏内的社交关系。良好的社交氛围能让玩家更有归属感,提高玩家对游戏的喜爱程度和忠诚度,也有助于玩家之间互相传播游戏,吸引更多新用户。

2.技术实现要点 

2.1.一次性活动与周期性活动

总体来看,活动有两类,一类是一次性活动,例如五一活动。另外一类,是周期性活动,例如每周六充值特惠。

可能有些朋友会说,五一也属于周期性活动啊,毕竟每年都有五一。但从实现游戏运营来说,不可能游戏一年都不用更新维护,我们只看在游戏运行期间,重复运行两次以上,则可认为是周期性活动。另外,正常来说,游戏策划也不会让今年的五一活动跟去掉的一模一样。

对于周期性活动,在活动结束后,需要重新注册,以便重新开始执行。而对于一次性活动,活动结束之后,就可以停止了。 

2.2.特殊活动支持

对于开服活动,合服活动这一类活动,比较特殊,这主要是说,这类活动的调度时间计算。对于普通活动,通过quartz或者spring cron等表达式可以计算出下一次执行的时间,但对于开服3天中午12点执行,就需要使用自定义日期表达式解析器了。 

2.3.高级功能

要设计一个高效,通用的活动框架,也不是一件简单的事。对于一些运营需要,例如在线热更,热更游戏奖励这件小事就不说了,最麻烦的是,在线开启活动,修改活动结束时间等需求,一旦处理不好,活动就要爆炸。如果是一个非常重要的充值活动,后果不堪设想。

此外,还有活动重用,例如,节操不多的策划打算把去年的五一活动再重用一次,这里就涉及到玩家活动数据的清除。有人可能觉得这个不难,只有在活动结束的时候,统一把参与的玩家活动数据清除就好了。但这里有两个问题,第一个是活动数据的存放问题,数据是存放在玩家身上呢,还是在活动表统一管理,如果保存在个人身上,不好统一清除,因为要把玩家从数据库捞取上来。另外一个问题,是活动结束之后,很多时间不能立马把数据清掉,因为策划还要求战况显示,显示玩家的活动结果,至于什么时候可以清除,依活动而异。

活动作为游戏服务器的核心业务,因为过于复杂,一般在遇到线上问题的时候,只能咨询原作者。不管是开发,还是策划。

3.代码实现

3.1.cron表达式解析

前面说了,活动有一次性活动,也有周期性活动,而且还有类似开服活动,合服活动等特殊形式。所以我们的cron表达式解析器必须同时支持多种格式。这里一开始是打算使用spring cron本身的表达式,但调研后发现spring cron连最简单的限定年份都不支持,更别说支持自定义格式了。最后,笔者还是选择了cron的鼻祖quartz调度框架。主要是因为它支持年份,至于开服活动等特殊形式,可以在业务上进行计算即可。

当然,也有一些项目曲线救国,直接参考spring scheduler的代码,重新设置了一套,让其支持自定义格式。个人不太推荐,因为使用者会有混淆,有可能在使用诸如@Scheduled等注解的时候会用错。

解析器接口定义:

/*** Cron表达式解析器*/
public interface CronParser {/*** 是否不本解析器允许的格式** @param expression* @return*/boolean isValidExpression(String expression);/*** 计算距离参考时间起的下一次日期** @param expression cron表达式* @param date       参考日期* @return*/Date getNextValidTimeAfter(String expression, Date date);
}

基于Quartz的默认解析器实现


public class QuartzParser implements CronParser {Logger logger = LoggerFactory.getLogger("CronParser");@Overridepublic boolean isValidExpression(String expression) {try {new CronExpression(expression);return true;} catch (ParseException var2) {return false;}}@Overridepublic Date getNextValidTimeAfter(String expression, Date start) {try {return new CronExpression(expression).getNextValidTimeAfter(start);} catch (ParseException e) {logger.error("parse %s failed".formatted(expression), e);return null;}}
}

解析器工具类,主要是以责任链的形式,当遇到一个表达式,它会按照顺序检查当前解析器能否解析,如果可以,则进行计算。否则,交由下一个解析器。


/*** cron表达式解析器上下文*/
public class CronContext {/*** 解析链,默认只有一个,就是quartz本身的Cron表达式解析器* 可根据需要,添加开服时间解析器,合服时间解析器等等*/private static List<CronParser> parserChain = new CopyOnWriteArrayList<>();static {// 默认解析器parserChain.add(new QuartzParser());}/*** 添加解析器到链表的最前面* @param parser*/public static void addParserBefore(CronParser parser) {parserChain.add(0, parser);}/*** 添加解析器到链表的最后面* @param parser*/public static void addParserAfter(CronParser parser) {parserChain.add(parser);}/*** 按照解析链,逐一解析表达式,如果表达式符合规则,则按当前节点解析器进行解析* @param expression 表达式,可以是cron表达式,也可以是开服时间等自定义cron表达式* @param date 开始时间* @return 下次执行时间 如果解析失败,或者没有下次执行时间,返回null*/public static Date getNextValidTimeAfter(String expression, Date date) {for (CronParser parser : parserChain) {if (parser.isValidExpression(expression)) {return parser.getNextValidTimeAfter(expression, date);}}return null;}}

支持开服活动等自定义格式

我们可以这样定义开服活动格式,如下:

 固定格式 秒 分 时 天 *

例如:表达式 59 59 12 1 * ,代表开服第一天的12点59分59秒。可以知道,开服活动一定是一次性活动,不可能是周期性活动。

代码如下:

/*** 开服天数*/
public class OpenServerCronParser implements CronParser {@Overridepublic boolean isValidExpression(String expression) {// 固定格式  秒 分 时 天 *// 59 59 12 1 *return expression.split(" ").length == 5;}@Overridepublic Date getNextValidTimeAfter(String expression, Date date) {Object openServerDate = SystemParameters.OpenServerDate.getValue();if (openServerDate == null) {return null;}String[] splits = expression.split(" ");Calendar next = Calendar.getInstance();next.setTime((Date) openServerDate);next.add(Calendar.DAY_OF_YEAR, NumberUtil.intValue(splits[3]));next.add(Calendar.HOUR_OF_DAY, NumberUtil.intValue(splits[2]));next.add(Calendar.MINUTE, NumberUtil.intValue(splits[1]));next.add(Calendar.SECOND, NumberUtil.intValue(splits[0]));return next.getTime();}}

3.2.活动框架

通用接口,定义了大部分活动功能


public interface ActivityHandler {void register();ActivityVo loadActivityInfo(PlayerEnt player);/*** 是否在活动期间,且可参与** @param player* @return*/boolean isInActivity(PlayerEnt player);int getActivityId();int takeReward(PlayerEnt player, int rewardId);/*** 活动开始*/void activityStart();/*** 活动结束*/void activityEnd();/*** 特殊时间调度* 返回cron与对应方法的映射* @return*/Map<String, Method> getCronMethods();
}

基础实现

public abstract class AbsActivityHandler implements ActivityHandler {private static Map<Integer, ActivityHandler> handlers = new HashMap<>();@Autowiredprotected ActivityScheduler activityScheduler;/*** 除了开始与结束时间外的其他定时任务* 这些是可选的,例如: prepare countdown* 直接在活动表进行配置,对应为cron与对应的方法名称*/@Getterprivate Map<String, Method> cronMethods = new HashMap<>();@Getter@Setterprotected long startTime;@Getter@Setterprotected long endTime;public static final int STATUS_START = 1; // 活动开始public static final int STATUS_CLOSED = 0; // 活动结束protected int status;/*** 最近一次的结束时间*/@Getterprotected Date lastEndDate;@PostConstructprivate void init() {handlers.put(getActivityId(), this);}public void register() {ActivityData activityData = GameContext.dataManager.queryById(ActivityData.class, getActivityId());// 注册定时任务方法// 格式: 0 0 12 1 1 ? 2025=prepare;if (StringUtils.isEmpty(activityData.getCron())) {return;}Map<String, String> cronToMethodMap = SplitUtil.toStringStringMap(activityData.getCron(), SplitUtil.SEMICOLON, SplitUtil.EQUAL);for (Map.Entry<String, String> entry : cronToMethodMap.entrySet()) {registerCronMethod(entry.getKey(), ReflectionUtils.findMethod(getClass(), entry.getValue()));}}public static ActivityHandler getHandler(int activityId) {return handlers.get(activityId);}@Overridepublic void activityStart() {this.status = STATUS_START;this.onActivityStart();}public abstract void onActivityStart();@Overridepublic void activityEnd() {this.status = STATUS_CLOSED;this.lastEndDate = new Date();this.onActivityEnd();}public abstract void onActivityEnd();@Overridepublic boolean isInActivity(PlayerEnt player) {return status == STATUS_START;}/*** 注册一个定时任务方法** @param method         要执行的方法* @param cronExpression cron表达式*/protected void registerCronMethod(String cronExpression, Method method) {cronMethods.put(cronExpression, method);}}

需要注意的是,activityStart与onActivityStart,activityEnd与onActivityEnd这两对方法。activityEnd代表活动调度的触发点,由活动调度触发,而onActivityEnd代表活动本身的业务细节。因为,周期性活动需要在活动结束的时候,进行重新调度,如果不提供onActivityEnd,则开发者很容易在实现活动业务的时候,在实现自己的活动结束逻辑时,忘了调用super.actvityEnd()而引起活动周期性触发被打断。

周期性活动

/*** 周期性活动处理器*/
public abstract class PeriodicActivityHandler extends AbsActivityHandler {@Overridepublic void activityEnd() {super.activityEnd();// 结束活动后,重新调度GameContext.getBean(ActivityScheduler.class).scheduleActivity(this);}
}

活动调度器


@Slf4j
@Component
public class ActivityScheduler {@Autowiredprivate TaskScheduler taskScheduler;/*** 记录活动调度* key为活动id* value为子map, 其中key为状态名称,例如:start, end, prepare,value为调度任务*/private final Map<Integer, Map<String, ScheduledFuture<?>>> scheduledTasks = new ConcurrentHashMap<>();/*** 调度活动*/public void scheduleActivity(AbsActivityHandler activity) {int activityId = activity.getActivityId();try {// 取消已存在的调度cancelScheduledActivity(activity.getActivityId());// 获取活动数据和时间信息ActivityData activityData = GameContext.dataManager.queryById(ActivityData.class, activity.getActivityId());String startCron = activityData.getStart();String endCron = activityData.getEnd();// 获取参考日期和当前时间Date referDate = getReferenceDate(activity);Date now = new Date();// 计算开始和结束时间Date startDate = CronContext.getNextValidTimeAfter(startCron, referDate);Date endDate = CronContext.getNextValidTimeAfter(endCron, referDate);// 如果结束时间在当前时间之后,则调度活动if (endDate.after(now)) {scheduleStartTask(activity, activityId, startDate, now, endDate);scheduleEndTask(activityId, endDate, now);scheduleCronTasks(activity, activityId, referDate, now);}} catch (Exception e) {LoggerUtils.error("", e);}}/*** 获取参考日期*/private Date getReferenceDate(AbsActivityHandler activity) {// 如果是刚起服,取一个很早之前的时间点作参考日期,否则无法开始一些起服已经过期的活动// 程序运行期间,参考日期以最近的活动结束日间为准return activity.getLastEndDate() != null ? activity.getLastEndDate() : DateUtil.parseDate("1970-01-01 00:00:00");}/*** 调度活动开始任务*/private void scheduleStartTask(AbsActivityHandler activity, int activityId, Date startDate, Date now, Date endDate) {Runnable startTask = () -> {LoggerUtils.info(LoggerFunction.ACTIVITY, "type", "start", "activityId", activity.getActivityId());// 计算开始与结束时间activity.setStartTime(System.currentTimeMillis());// 计算活动结束时间戳activity.setEndTime(endDate.getTime());// 调用活动开始方法ActivityHandler handler = AbsActivityHandler.getHandler(activityId);handler.activityStart();};if (startDate.before(now)) {// 如果开始时间已经过期,但没过结束时间,则直接启动startTask.run();} else {ScheduledFuture<?> startFuture = taskScheduler.schedule(startTask,startDate.toInstant());registerTask(activityId, "start", startFuture);}}/*** 调度活动结束任务*/private void scheduleEndTask(int activityId, Date endDate, Date now) {Runnable endTask = () -> {LoggerUtils.info(LoggerFunction.ACTIVITY, "type", "end", "activityId", activityId);// 调用活动结束方法ActivityHandler handler = AbsActivityHandler.getHandler(activityId);handler.activityEnd();};if (endDate.after(now)) {// 调度活动结束ScheduledFuture<?> endFuture = taskScheduler.schedule(endTask,endDate.toInstant());registerTask(activityId, "end", endFuture);}}/*** 调度其他特殊时间任务*/private void scheduleCronTasks(AbsActivityHandler activity, int activityId, Date referDate, Date now) {activity.getCronMethods().forEach((cron, method) -> {Date cronDate = CronContext.getNextValidTimeAfter(cron, referDate);if (cronDate.after(now)) {Runnable cronTask = () -> {LoggerUtils.info(LoggerFunction.ACTIVITY, "type", method.getName(), "activityId", activityId);// 调用活动结束方法ActivityHandler handler = AbsActivityHandler.getHandler(activityId);try {method.invoke(handler);} catch (Exception e) {LoggerUtils.error("", e);}};ScheduledFuture<?> cronFuture = taskScheduler.schedule(cronTask,cronDate.toInstant());registerTask(activityId, cron, cronFuture);}});}private void registerTask(int activityId, String name, ScheduledFuture<?> task) {// 如果有旧的任务,取消Map<String, ScheduledFuture<?>> tasks = scheduledTasks.computeIfAbsent(activityId, k -> new ConcurrentHashMap<>());tasks.put(name, task);}/*** 取消活动调度*/private void cancelScheduledActivity(int activityId) {Map<String, ScheduledFuture<?>> tasks = scheduledTasks.remove(activityId);if (tasks != null) {for (ScheduledFuture<?> task : tasks.values()) {task.cancel(false);}}}
} 

这里比较复杂,需要重点关注假如服务器启动的时候,活动过了开始时间,但没到结束时间,这些活动要让它跑起来。

活动最重要的两个触发点,一个是活动开始,另外一个是活动结束。除此之外,还有一些特殊时间点,比如准备阶段,结束前三分钟倒计时,这些需求,我们可以把配置放到拓展项里,如下所示

这代表 0 0 10 1 1 ? 2025时刻执行handler的prepare方法,0 0 12 30 12 ? 2025执行handler的countdown。需要拓展什么时间点,由活动子类+配置决定,极具拓展性。

相关文章:

  • C++拷贝构造函数详解
  • Wireshark网络抓包工具基础使用教程
  • 4.5 使用busybox制作根文件系统
  • 开源ERP系统对比:Dolibarr、ERPNext与Odoo
  • AI大模型-解决开发环境配置不足问题
  • [FPGA Video] AXI4-Stream Remapper
  • stm32 hal库 SPI使用(二)硬件SPI的HAL库函数调用
  • spring-- 事务失效原因及多线程事务失效解决方案
  • Flutter——数据库Drift开发详细教程(二)
  • Flutter AppBar 详解
  • “会话技术”——Cookie_(2/2)原理与使用细节
  • 【二叉树】java源码实现
  • 中小企业MES系统概要设计
  • 数字智慧方案6213丨智慧园区规划方案(63页PPT)(文末有下载方式)
  • 【学习笔记】第十章:序列建模:递归神经网络(RNN)
  • Python 数据智能实战 (8):基于LLM的个性化营销文案
  • Redis总结及设置营业状态案例
  • 分发饼干之 双数组匹配问题 (双指针 or 二分)
  • 【质量管理】现代TRIZ中问题识别中的功能分析——相互接触分析
  • 【算法题】荷兰国旗问题[力扣75题颜色分类] - JAVA
  • 看着不爽就滚蛋!郑州大学第一附属医院一科室公众号被曝运营人员辱骂他人
  • 江西望仙谷回应“游客凌晨等不到接驳车”:已限流,接驳车运行时间延长
  • 魔都眼|咖啡节上上海小囡忍不住尝了咖啡香,母亲乐了
  • 航海王亚洲巡展、工厂店直销……上海多区推出“五五购物节”活动
  • 美航母撞船后又遇战机坠海,专家:长时间作战部署疲于奔命是主因
  • 事关广大农民利益,农村集体经济组织法5月1日起施行