Article

Unity WebGL 小游戏实战 07:运营活动系统(限时任务、签到与活动开关)

路线阶段:Unity WebGL 小游戏实战第 7 章。
本章目标:让版本上线后也能持续运营,不依赖每次改代码发包。

学习目标

完成本章后,你应该能做到:

  1. 设计活动配置模型(时间窗、条件、奖励、展示)。
  2. 实现签到、限时任务、活动商店三类基础活动。
  3. 支持活动远程开关与灰度生效。
  4. 将活动奖励与经济系统、安全校验和存档打通。

为什么必须有活动系统

只靠固定关卡内容,常见问题:

  1. 老玩家目标缺失,活跃下降。
  2. 节假日或版本节点无法快速运营。
  3. 变更一个活动奖励就要重新发包。

活动系统目标:配置驱动 + 远程控制 + 可追踪发奖。

活动配置模型

public enum EventType
{
    DailySignIn = 0,
    LimitedMission = 1,
    EventShop = 2
}

[Serializable]
public sealed class LiveEventConfig
{
    public string EventId;
    public int Version;
    public EventType Type;

    public long StartUnixMs;
    public long EndUnixMs;
    public bool Enabled;

    public string Title;
    public string Desc;

    public string ConditionKey;
    public string RewardKey;
}
[Serializable]
public sealed class RewardConfig
{
    public string RewardKey;
    public int Gold;
    public int Crystal;
    public int ItemId;
    public int ItemCount;
}

活动仓库与远程开关

public interface ILiveEventProvider
{
    IEnumerator LoadAsync(Action<List<LiveEventConfig>> onDone, Action<string> onError);
}

public sealed class LiveEventRepository
{
    private readonly Dictionary<string, LiveEventConfig> _map = new Dictionary<string, LiveEventConfig>(64);

    public void Reload(List<LiveEventConfig> list)
    {
        _map.Clear();
        for (var i = 0; i < list.Count; i++)
        {
            _map[list[i].EventId] = list[i];
        }
    }

    public List<LiveEventConfig> GetActive(long nowMs)
    {
        var result = new List<LiveEventConfig>(16);

        foreach (var kv in _map)
        {
            var e = kv.Value;
            if (!e.Enabled) continue;
            if (nowMs < e.StartUnixMs) continue;
            if (nowMs > e.EndUnixMs) continue;
            result.Add(e);
        }

        return result;
    }

    public LiveEventConfig Get(string eventId)
    {
        LiveEventConfig e;
        return _map.TryGetValue(eventId, out e) ? e : null;
    }
}

玩家活动进度

[Serializable]
public sealed class LiveEventProgress
{
    public string EventId;

    public int ClaimedCount;
    public long LastClaimUnixMs;

    public int MissionValue;
    public bool MissionDone;
}

[Serializable]
public sealed class LiveEventSaveData
{
    public List<LiveEventProgress> ProgressList;
}

活动服务

public sealed class LiveEventService
{
    private readonly LiveEventRepository _repo;
    private readonly EconomyService _economy;
    private readonly SaveService _save;
    private readonly RewardCatalog _rewardCatalog;
    private readonly EventBus _eventBus;

    private readonly Dictionary<string, LiveEventProgress> _progress = new Dictionary<string, LiveEventProgress>(64);

    public LiveEventService(
        LiveEventRepository repo,
        EconomyService economy,
        SaveService save,
        RewardCatalog rewardCatalog,
        EventBus eventBus)
    {
        _repo = repo;
        _economy = economy;
        _save = save;
        _rewardCatalog = rewardCatalog;
        _eventBus = eventBus;
    }

    public void LoadProgress(LiveEventSaveData data)
    {
        _progress.Clear();
        if (data == null || data.ProgressList == null) return;

        for (var i = 0; i < data.ProgressList.Count; i++)
        {
            _progress[data.ProgressList[i].EventId] = data.ProgressList[i];
        }
    }

    public bool TryClaim(string eventId, long nowMs, out string reason)
    {
        var cfg = _repo.Get(eventId);
        if (cfg == null)
        {
            reason = "event_missing";
            return false;
        }

        if (!cfg.Enabled || nowMs < cfg.StartUnixMs || nowMs > cfg.EndUnixMs)
        {
            reason = "event_inactive";
            return false;
        }

        var p = GetOrCreateProgress(eventId);

        if (cfg.Type == EventType.DailySignIn)
        {
            if (IsSameDay(p.LastClaimUnixMs, nowMs))
            {
                reason = "already_claimed_today";
                return false;
            }
        }
        else if (cfg.Type == EventType.LimitedMission)
        {
            if (!p.MissionDone)
            {
                reason = "mission_not_done";
                return false;
            }
        }

        var reward = _rewardCatalog.Get(cfg.RewardKey);
        if (reward == null)
        {
            reason = "reward_missing";
            return false;
        }

        GrantReward(reward);

        p.ClaimedCount += 1;
        p.LastClaimUnixMs = nowMs;

        _save.MarkDirty();
        _eventBus.Publish("LiveEventClaimed", eventId);

        reason = "ok";
        return true;
    }

    public void AddMissionProgress(string eventId, int value)
    {
        var p = GetOrCreateProgress(eventId);
        p.MissionValue += value;

        var cfg = _repo.Get(eventId);
        if (cfg != null && cfg.Type == EventType.LimitedMission)
        {
            var target = ParseMissionTarget(cfg.ConditionKey);
            if (p.MissionValue >= target)
            {
                p.MissionDone = true;
                _eventBus.Publish("LiveMissionDone", eventId);
            }
        }
    }

    private LiveEventProgress GetOrCreateProgress(string eventId)
    {
        LiveEventProgress p;
        if (_progress.TryGetValue(eventId, out p))
        {
            return p;
        }

        p = new LiveEventProgress { EventId = eventId };
        _progress[eventId] = p;
        return p;
    }

    private void GrantReward(RewardConfig reward)
    {
        if (reward.Gold > 0)
            _economy.Add(CurrencyType.RunGold, reward.Gold, "event_reward");

        if (reward.Crystal > 0)
            _economy.Add(CurrencyType.MetaCrystal, reward.Crystal, "event_reward");

        if (reward.ItemId > 0 && reward.ItemCount > 0)
            _eventBus.Publish("InventoryAdd", new ItemPayload(reward.ItemId, reward.ItemCount));
    }

    private static bool IsSameDay(long aMs, long bMs)
    {
        if (aMs <= 0 || bMs <= 0) return false;
        var a = DateTimeOffset.FromUnixTimeMilliseconds(aMs).UtcDateTime.Date;
        var b = DateTimeOffset.FromUnixTimeMilliseconds(bMs).UtcDateTime.Date;
        return a == b;
    }

    private static int ParseMissionTarget(string key)
    {
        // e.g. "kill:50"
        if (string.IsNullOrEmpty(key)) return 999999;
        var idx = key.IndexOf(':');
        if (idx < 0) return 999999;
        int value;
        return int.TryParse(key.Substring(idx + 1), out value) ? value : 999999;
    }
}

public struct ItemPayload
{
    public int ItemId;
    public int Count;

    public ItemPayload(int itemId, int count)
    {
        ItemId = itemId;
        Count = count;
    }
}

运营活动 UI

建议面板结构:

  1. 活动列表页(按剩余时间排序)。
  2. 活动详情页(规则、进度、奖励)。
  3. 领奖按钮状态(可领/未达成/已领取)。

活动校验规则

上线前自动校验:

  1. 活动时间重叠冲突。
  2. 奖励 key 存在性。
  3. 同一活动多次领奖上限。
  4. 时间格式和时区一致性。

与前面系统联动

  1. 经济系统:统一奖励发放入口。
  2. 存档系统:持久化活动进度。
  3. 广告系统:可配置“看广告额外领奖”。
  4. 流程状态机:活动面板在菜单态或结算态打开。

WebGL 注意点

  1. 活动配置建议短缓存+签名校验。
  2. 本地时间可被篡改,关键领奖建议服务端校验(后续扩展)。
  3. 活动列表刷新要做失败重试与回退缓存。

验收清单

  1. 签到活动每天仅能领取一次。
  2. 限时任务达成后可正确领奖。
  3. 活动结束后入口自动隐藏。
  4. 重启游戏后活动进度与领奖状态一致。

常见坑

坑 1:用本地时间直接判定所有奖励

可被改系统时间刷奖励。应至少加入防回退策略和签名验证。

坑 2:领奖逻辑分散在多个 UI 按钮

容易重复发奖。应统一由活动服务处理。

坑 3:活动配置缺少版本号

线上回滚和问题定位困难。每份配置必须带版本。

本月作业

实现一个“7日签到 + 周任务”活动:

  1. 签到每日一档奖励,支持补签道具。
  2. 周任务目标:击败 300 敌人。
  3. 结算页展示活动进度条和领取入口。

下一章进入 Unity WebGL 小游戏实战 08:埋点体系与数据看板(留存、转化、平衡数据闭环)。