MeteorCat / 网络游戏在线奖励设计

Created Thu, 14 Mar 2024 13:15:00 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

网络游戏在线奖励设计

在线奖励是网络游戏比较常见的, 客户端表现常见:

  1. 客户端加载在线时长奖励表
  2. 确定目前最后一次领取完时间戳
  3. 确认最后领取时间与当前时间戳差距
  4. 显示差距时间秒数确定可以被领取
  5. 点击推送指定时间戳满足条件奖励id
  6. 服务端判断是否满足,满足更新玩家资源信息

这种设计最常见早期二次元游戏品类的体力槽设计, 相当于每分钟回复1点体力然后玩家依靠体力解锁游玩副本.

这里先以最基础的在线体力增值做样例, 配置数据库字段和业务Actor来设计对应功能.

首先必须要在玩家信息实体当中挂载对应的体力相关字段, 主要用于记录玩家体力值相关数据:

/**
 * 玩家实体对象
 * 异步保存数据到数据库之中, 同时挂载在进程内存中用于读写
 */
@Entity
@Table(name = "tbl_player_info")
public class PlayerInfoModel {

    /**
     * 玩家第三方登录uid, 这里采用主键自递增记录
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(nullable = false, columnDefinition = "BIGINT COMMENT '主键ID,同时也是标识玩家UID'")
    private Long uid;


    /// 以下体力值系统是必须的  ------------------------------------------------------

    /**
     * 体力值
     */
    @Column(nullable = false, columnDefinition = "INT COMMENT '体力值'")
    private Integer health = 10;


    /**
     * 体力值最后更新时间
     */
    @Column(nullable = false, columnDefinition = "BIGINT COMMENT '体力值最后更新时间'")
    private Long healthUpdateTime = 0L;


    /// 其他略....
}

这里记录 health(体力值)healthUpdateTime(最后更新体力时间) 就是关键点, 主要就是依靠时间戳整除方式, 举个例子为例:

# 假设时间: 2023-12-01 15:00:00
# 这时候获取到毫秒时间戳: 1701414000000

# 按照策划案来说, 体力增长一般都是以1分钟刷新的更新速度
# 这里需要给策划预留给配表变量让他可以修改每分钟增长体力值, 开发初期是可以自己设定
# 偏移当前时间1分钟: 2023-12-01 15:01:30
# 这时候获取到毫秒级时间戳: 1701414090000

# 问题就来了: 怎么计算出应该增长体力值递增值?
# 实际简单计算出剩余分钟偏移值( offset/60000/1000 ), 最后补上 Math.min 取出最小值

> 1701414090000(当前时间) - 1701414000000(上次更新过时间)
= 90000(偏移毫秒时间) / 60000(每分钟毫秒,换算成分钟单位)
= Integer(1.5) (过去的分钟)
≈ 1(计算机程序直接除以int类型会直接去除小数点取主位数值)
最后得出增加 1 点体力

# 以上面风格来实现出如果超过10分钟换算下是否正确
# 以 2023-12-01 15:10:00 = 1701414600000 作为当前计算

> 1701414600000 - 1701414000000
= 10(直接得出,过程略)
最后得出需要增长 10 点体力, 符合预期计算

上面就是整体的逻辑处理, 剩下只需要在玩家登录时候挂分钟定时器每分钟检索内存上的最后更新时间即可.

在登录初期必须计算上次体力更新到现在添加的体力值, 更新到最新之后再绑定定时器监测推送体力变动.

这里先编写编写个挂机在线单元( PlayerOnline )做初版设计:

/**
 * 在线增长
 */
@EnableActor(owner = PlayerOnlineActor.class)
public class PlayerOnlineActor extends ActorConfigurer {

    /**
     * 会话日志
     */
    final Logger logger = LoggerFactory.getLogger(getClass());


    /**
     * 玩家信息服务
     */
    final PlayerInfoServer playerInfoServer;


    /**
     * 周期增长之后最大体力
     */
    private Integer healthMax;


    /**
     * 构造方法
     *
     * @param playerInfoServer 玩家信息服务
     */
    public PlayerOnlineActor(PlayerInfoServer playerInfoServer) {
        this.playerInfoServer = playerInfoServer;
    }


    @Override
    public void init() {
        // 加载策划配置表, 这里因为演示暂时不处理外部表
        // 这里应该配置体力最大值增长, 这里先静态编写到内部
        healthMax = 200;
    }

    @Override
    public void destroy() {
        // 退出程序应该不需要处理
    }


    /**
     * 挂载在线增长体力人物, 允许被进程内部调用
     */
    @ActorMapping(value = Protocols.SYS_CHANGE_HEALTH, state = ActorStatus.Memory)
    public void increase(WebsocketApplication app, WebSocketSession session, Long uid) {
        // 注意这里定时可以采用1分钟定时触发
        ActorEventContainer container = getContainer();
        container.schedule(() -> update(app, container, session, uid), 60L, TimeUnit.SECONDS);
    }


    /**
     * 周期更新体力
     *
     * @param container 容器
     * @param session   会话
     * @param uid       玩家UID
     */
    private void update(WebsocketApplication app, ActorEventContainer container, WebSocketSession session, Long uid) {
        if (!session.isOpen()) {
            return;
        }

        // 获取最后更新时间, 和当前时间判断是否超过指定体力
        PlayerInfoModel model = playerInfoServer.findByUid(uid);

        // 判断体力是否满足最大值, 到达最大值不需要去处理
        Integer health = model.getHealth();
        if (health >= healthMax) {
            // 继续等待下次延迟调用
            container.schedule(() -> update(app, container, session, uid), 60L, TimeUnit.SECONDS);
            return;
        }

        // 获取当前时间戳
        Long now = System.currentTimeMillis();
        Long before = model.getHealthUpdateTime();
        long offset = now - before;

        // 大于等于1分钟更新体力
        if (offset >= 60000) {
            int finalHealth = updateHealth(offset, health, model, now);
            app.push(session, Protocols.PLAYER_CHANGE_HEALTH, new HashMap<>(2) {{
                put("next", now + 60000);
                put("health", finalHealth);
            }});
        }


        // 继续等待下次延迟调用
        container.schedule(() -> update(app, container, session, uid), 60L, TimeUnit.SECONDS);
    }


    /**
     * 更新体力
     * 外部可能会调用具体体力更新
     *
     * @param offset 偏移毫秒
     * @param health 体力值
     * @param model  玩家实体
     * @param now    当前时间
     */
    @ActorMapping(value = Protocols.PLAYER_CHECK_HEALTH, state = {ActorStatus.Memory})
    public int updateHealth(long offset, int health, PlayerInfoModel model, Long now) {
        // 计算延迟了多少分钟, 按照每分钟增长1体力来计算
        // 更新最新体力信息
        offset = offset / 60000;
        health += Math.toIntExact(offset);
        model.setHealth(Math.min(health, healthMax));
        model.setHealthUpdateTime(now);
        playerInfoServer.mark(model.getUid(), model);
        logger.debug("更新体力:{}", health);

        // 返回更新之后的体力
        return health;
    }
}

这就是个标准管理玩家的在线追加体力的 Actor, 后续就是在登录接口之中调用挂起服务, 像下面处理:

/**
 * 授权登录相关
 */
@EnableActor(owner = AuthorizedActor.class)
public class AuthorizedActor extends ActorConfigurer {

    /**
     * 对外登录授权验证接口
     *
     * @param app     应用
     * @param session 会话
     * @param data    数据
     */
    @ActorMapping(value = Protocols.AUTH_LOGIN, state = {ActorStatus.None})
    public void login(WebsocketApplication app, WebSocketSession session, JsonNode data) {
        // 登录 secret 验证步骤, 略


        // 获取数据库玩家信息, 不存在就创建数据库实体, 默认读取策划配表


        // 首次登录需要判断玩家存在并且计算上次登录的时间至今需要补充的体力值, 这里略

        // 挂载定时延迟脚本任务, 转发到对应 Actor 挂起定时脚本
        ActorEventContainer container = getContainer();
        ActorConfigurer configurer = container.get(Protocols.SYS_CHANGE_HEALTH);
        if (container != null) {
            configurer.invoke(Protocols.SYS_CHANGE_HEALTH, ActorStatus.Memory, app, session, uid);
        }
    }
}