路线阶段:Unity WebGL 小游戏实战第 7 章。
本章目标:让版本上线后也能持续运营,不依赖每次改代码发包。
学习目标
完成本章后,你应该能做到:
- 设计活动配置模型(时间窗、条件、奖励、展示)。
- 实现签到、限时任务、活动商店三类基础活动。
- 支持活动远程开关与灰度生效。
- 将活动奖励与经济系统、安全校验和存档打通。
为什么必须有活动系统
只靠固定关卡内容,常见问题:
- 老玩家目标缺失,活跃下降。
- 节假日或版本节点无法快速运营。
- 变更一个活动奖励就要重新发包。
活动系统目标:配置驱动 + 远程控制 + 可追踪发奖。
活动配置模型
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
建议面板结构:
- 活动列表页(按剩余时间排序)。
- 活动详情页(规则、进度、奖励)。
- 领奖按钮状态(可领/未达成/已领取)。
活动校验规则
上线前自动校验:
- 活动时间重叠冲突。
- 奖励 key 存在性。
- 同一活动多次领奖上限。
- 时间格式和时区一致性。
与前面系统联动
- 经济系统:统一奖励发放入口。
- 存档系统:持久化活动进度。
- 广告系统:可配置“看广告额外领奖”。
- 流程状态机:活动面板在菜单态或结算态打开。
WebGL 注意点
- 活动配置建议短缓存+签名校验。
- 本地时间可被篡改,关键领奖建议服务端校验(后续扩展)。
- 活动列表刷新要做失败重试与回退缓存。
验收清单
- 签到活动每天仅能领取一次。
- 限时任务达成后可正确领奖。
- 活动结束后入口自动隐藏。
- 重启游戏后活动进度与领奖状态一致。
常见坑
坑 1:用本地时间直接判定所有奖励
可被改系统时间刷奖励。应至少加入防回退策略和签名验证。
坑 2:领奖逻辑分散在多个 UI 按钮
容易重复发奖。应统一由活动服务处理。
坑 3:活动配置缺少版本号
线上回滚和问题定位困难。每份配置必须带版本。
本月作业
实现一个“7日签到 + 周任务”活动:
- 签到每日一档奖励,支持补签道具。
- 周任务目标:击败 300 敌人。
- 结算页展示活动进度条和领取入口。
下一章进入 Unity WebGL 小游戏实战 08:埋点体系与数据看板(留存、转化、平衡数据闭环)。