第10章 - 进度系统与日常任务

packages/progress 是整个项目里唯一“只谈规则、不谈画面”的包:它不 import Phaser、不碰 DOM、不读 Date.now()(时间一律从参数或注入的时钟来),因此可以在 Node 里用 Vitest 逐条验证。十条任务的推荐、五维状态的衰减、竹笋与碎片的结算、连续天数与贴纸、成长阶段、存档迁移全部在这里;外壳的 Pinia store 只是它的一层投影。

这个包一共十个源文件,职责切得很细:

文件 职责 关键导出
save.ts 空档构造、坏字段体检、迁移链、30 天裁剪 emptySave、migrate、ensureDay、pruneDays
day.ts 本地日期键、时段、日期差 dayKey、currentSlot、daysBetween、addDays
daily.ts 确定性推荐 recommendedTasks、hashString、seededRandom
stats.ts 五维状态、衰减、温柔提示 applyStatDelta、decayStats、statHints
rewards.ts 星星、竹笋、碎片、连续天数、贴纸 starsFor、completionReward、nextStreak
growth.ts 四个成长阶段 STAGES、stageForXp、xpToNextStage
manager.ts 编排与落盘 ProgressManager
catalog.ts / intimacy.ts / diary.ts 装扮、亲密度、日记 见第 11 章

10.1 存档长什么样

存档是纯数据,类型定义在 packages/game-kit/src/contract.ts:

export interface SaveData {
  schemaVersion: number;
  updatedAt: number;
  pet: PetState;
  tasks: Record<TaskId, TaskProgress>;
  /** 只保留最近 30 天,避免存档无限增长 */
  days: Record<string, DayRecord>;
}

三层结构对应三种读取方式:

  • pet 是小屋的第一屏:名字、成长值、五维状态、竹笋、碎片、已解锁物件、贴纸、亲密度、今天做过的亲昵动作、穿在身上的衣服、房间布置、累计喂食次数;
  • tasks 是十个任务的历史:最高星级(只增不减)、完成次数、最后游玩时间、最后完成日、当前连续天数、历史最佳连续天数;
  • days 是最近 30 天的日记录:完成了哪些任务(去重)、当天奖励合计、当天得到的贴纸、做过的亲昵动作、玩耍秒数、休息提醒是否已收起。

空档不是“全零”,而是经过设计的一屏:

// packages/progress/src/save.ts
const DEFAULT_STATS: Record<StatKey, number> = {
  fullness: 72, energy: 80, clean: 76, mood: 88, health: 82,
};

export function emptyPet(now: number): PetState {
  const defaults = defaultEquipment();
  return {
    name: '竹竹',
    xp: 0,
    stats: { ...DEFAULT_STATS },
    lastDecayDay: dayKey(now),
    shoots: 3,
    fragments: 0,
    unlocked: [],
    stickers: [],
    intimacy: 0,
    careDays: {},
    equipped: defaults.equipped,
    room: defaults.room,
    feeds: 0,
  };
}

初始竹笋是 3,不是 0:新玩家至少能喂一次竹竹,先建立“奖励有用”的因果,再谈攒钱买装扮。初始状态都在 70–88 之间,小屋不会一打开就挂满“想刷牙”“想睡觉”的提示。

emptySave() 会把十个任务全部预置成空进度,而不是等第一次玩再补。这样界面读 tasks[id] 永远拿得到对象,不用写一堆可选链;days 则相反,只保证今天这一条,历史日期按需出现。存档大小因此是可预期的:十份任务进度 + 三十份日记录,最多几十 KB,一次事务写完。

10.2 迁移:坏字段丢一个,不清整份

SAVE_SCHEMA_VERSION 当前是 1。迁移链是一张“从旧版本升到下一版本”的函数表:

const MIGRATIONS: Record<number, (raw: Raw, now: number) => Raw> = {};

表现在是空的,因为还没有 v2;但流程先立起来。migrate() 的顺序是:

  1. 不是对象 → 直接返回空档;
  2. 版本号高于当前 → 视为“来自未来的存档”,整体清空(旧版本读不懂新字段,继续用只会写坏数据);
  3. 版本号低于当前 → 逐级调用迁移函数,缺一步就停下;
  4. 对 pet / tasks / days 逐字段体检;
  5. 确保今天有一条日记录,再裁剪到 30 天。

体检的原则是“坏字段只丢那一个”:

// packages/progress/src/save.ts(节选)
out[id] = {
  taskId: id,
  stars: intInRange(raw.stars, 0, 3, 0) as 0 | 1 | 2 | 3,
  completions: positiveInt(raw.completions),
  lastPlayedAt: typeof raw.lastPlayedAt === 'number' && Number.isFinite(raw.lastPlayedAt)
    ? raw.lastPlayedAt : null,
  lastCompletedDay:
    typeof raw.lastCompletedDay === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.lastCompletedDay)
      ? raw.lastCompletedDay
      : null,
  streak: positiveInt(raw.streak),
  bestStreak: positiveInt(raw.bestStreak),
};

单测里有几组很能说明问题的断言:stars: 9 被夹到 3;shoots: 'many' 变成 0;unlocked: ['shoes-red', 'nope'] 只保留真实存在的物件,并补上免费自带的墙纸与地毯;days 里 40 天只留 30 天;schemaVersion: 99 整体回空。这些都不是理论情况——旧版本、手动改过的存档、被其他标签页写坏的存档都会走到这里。

体检没有用 zod。内容 JSON 的 schema 校验发生在构建期(content:check),存档则必须在运行时、在浏览器里完成,且不能把 zod 拖进首屏。手写的 positiveInt / intInRange / asTaskId 只有几十行,行为足够明确:不认识的 TaskId 丢掉、负数归零、超范围夹紧、非法日期置空。代价是字段新增时要记得同步体检函数,收益是首屏不多约 25 KB。

裁剪也有一个容易漏掉的细节:裁剪发生在“补上今天”之后。先 ensureDay() 保证今天有记录,再 pruneDays() 按日期键排序保留最近 30 条;顺序反过来的话,一个很久没打开的存档可能先把今天裁掉,再加回来,白跑一次。pruneDays() 输出时又按由旧到新排回,和 weekSummary() 的读取顺序一致。

10.3 日期与时段:用孩子墙上的“今天”

日期键一律是本地时区的 YYYY-MM-DD:

export function dayKey(now: number): string {
  const date = new Date(now);
  return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
}

不能用 toISOString().slice(0, 10):那会按 UTC 切日。东八区的孩子早上 7 点打开,UTC 还停在前一天的 23 点,日期键会差一天;这个错误在成人产品里也许只是统计偏差,在儿童产品里会直接导致“今天做过的任务不算今天”。

时段按小时切三档:

时段 小时范围 用途
morning 0:00–10:59 早上推荐、问候语
noon 11:00–16:59 中午推荐
evening 17:00–23:59 晚上推荐、睡前任务

daysBetween(from, to) 把日期键转成 Date.UTC 再相减,只用于算“相差几天”。这样跨夏令时也不会出现 23 小时或 25 小时导致的半天误差;非法日期返回 0,坏存档按“没有跨天”处理,绝不会因为一个坏字段触发七连衰减。

时段边界定在 11 点与 17 点,是按学龄前与低年级孩子的作息粗切的:11 点前算早上(起床到上午点心),11–17 点算中午(午餐、午睡、下午),17 点后算晚上(晚饭、洗澡、睡前)。它不是精确的生理时钟,但足够让“早上推荐刷牙、晚上推荐洗澡”符合直觉。孩子下午四点打开,看到的是中午组;过了五点再刷新,推荐会自动切到晚上组。

10.4 今日推荐:日期加时段就是种子

推荐要同时满足两个看似矛盾的要求:每天有变化,同一天刷多少次都一样。实现用确定性伪随机:

// FNV-1a
export function hashString(text: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < text.length; i += 1) {
    hash ^= text.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}

// mulberry32
export function seededRandom(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

种子是 dateKey + '|' + slot。同一天早上打开十次,推荐顺序完全一致;第二天种子变了,组合才变。纯逻辑层禁用 Math.random(ESLint 守卫),原因不只是可测试:孩子刷新一次页面就换一批任务,会让他找不到刚才想做的那件事。

挑选规则写得很直白:

take(tasksForSlot(tasks, slot), 2);                 // 当前时段 2 个
for (const other of neighbourSlots(slot)) {
  take(tasksForSlot(tasks, other), 1);              // 前后时段各 1 个
}
take(tasks, target - picked.length);                // 不够再补齐

最后按“当前时段优先、其余按内容表顺序”排序,保证卡片排布稳定。任务页取 4 个,小屋只取 2 个:小屋是孩子每天的第一屏,摆一整屏卡片会让他挑花眼。

十个任务覆盖三个时段:

任务 模板 时段 时长 奖励(竹笋 / 成长值)
刷牙 brush scrub 早上、晚上 45 s 2 / 12
洗手 wash scrub 早、中、晚 40 s 2 / 10
吃饭 feed feed 早、中、晚 70 s 3 / 14
穿衣 dress dress 早上 55 s 2 / 12
洗澡 bath scrub 晚上 70 s 3 / 14
睡觉 sleep order 晚上 50 s 3 / 14
整理 tidy tidy 中午、晚上 60 s 2 / 12
运动 sport rhythm 早上、中午 55 s 3 / 13
情绪 emotion pick 早、中、晚 45 s 2 / 12
学习 learn learn 早上、中午 50 s 2 / 13

每个时段至少两条任务,content:check 会把“某个时段少于 2 个”当成构建错误。否则孩子早上打开只看到一条任务,推荐就退化成命令。

10.5 五维状态:只增不减,跨天轻衰减

stats.ts 的常量先立三条底线:

export const STAT_MIN = 10;
export const STAT_MAX = 100;
export const HINT_THRESHOLD = 40;

下限是 10 而不是 0:状态永远不归零、不扣命、不结束游戏。任务收益只加不减;唯一会下降的是跨天自然衰减,而且一天只掉一次。这不是数值偷懒,而是产品约束:如果某一项能归零,界面就必然要设计“濒危”状态、失败动画与挽救手段,而那套语言对 4–8 岁的孩子只会制造压力。下限 10 让状态条永远留一小截,视觉上“还有力气”,行动上永远有得救。每天的自然衰减如下:

状态 每天衰减 说明
饱腹 fullness 10 吃饭收益最高(+22)
精力 energy 9 睡觉收益最高(+26)
清洁 clean 10 洗澡 +24、刷牙 +16
心情 mood 7 情绪任务 +15
健康 health 4 掉得最慢,运动 +12

衰减有 7 天上限:decayStats(stats, 100) 也只按 7 次计算。孩子跟着家人出门一周没打开,回来时竹竹只是“有点想你”,不是“奄奄一息”。

完成任务的收益用 applyStatDelta 结算;当天重复玩时传 factor = 0.5,每项至少 +1(Math.max(1, Math.ceil(value * factor)))。这条“至少 1”很关键:重复玩不该变成零收益,否则孩子会觉得自己白做了。

低状态提示只做两件事:找最低的一到两项,映射到对应任务:

const HINT_TASKS: Record<StatKey, TaskId> = {
  fullness: 'feed', energy: 'sleep', clean: 'bath',
  mood: 'emotion', health: 'sport',
};

低于 40 才提示,最多两条。文案是“竹竹想刷牙啦”“想洗个香香的澡”,不是“清洁度过低”。没有低项时返回空数组——小屋不会挂一句无病呻吟的提示。状态条的颜色也全是低饱和的麦色、薰衣草、水蓝、腮红、竹绿,没有一项是警戒红。

10.6 奖励与连续天数:没有 0 颗星

星星是“这一局玩得怎么样”的正向反馈,不是评分羞辱:

export function starsFor(mistakes: number): Stars {
  if (mistakes <= 1) return 3;
  if (mistakes <= 3) return 2;
  return 1;
}

失误 0–1 次三颗,2–3 次两颗,更多一颗;没有 0 颗。星星与速度、剩余时间都无关,孩子不会因为慢一点就拿不到星。

奖励结算分三块:

  • 竹笋与成长值:当天第一次完成给全份,重复玩给一半,且最低 1 根竹笋、3 点成长值;
  • 装扮碎片:只有“今天第一次完成”且“算上这一次,今天完成了第 3、6、9… 个不同任务”时给 1 枚;
  • 贴纸:同一任务连续 3 天(以及之后的 6、9…天)完成,且还没有这张贴纸时给一张;每个任务只有一张。

连续天数的规则在 nextStreak():

if (lastCompletedDay === today) return Math.max(1, previousStreak);
if (lastCompletedDay === yesterdayOf(today)) return Math.max(0, previousStreak) + 1;
return 1;

同一天再玩一次不涨也不掉;昨天玩过就 +1;断了从 1 重新数。断签不清任何数据,也不提示“你断了”——最高星级、完成次数、已得贴纸全都在,只是连续数字回到 1。这是儿童产品和打卡产品的分水岭:前者不需要用损失厌恶驱动。

贴纸也不是货币。它没有数量、不能消费、不影响任何数值,只在任务卡与日记的贴纸册上亮起来。十格贴纸册永远显示十格,没得到的是灰度剪影——孩子能看到“还有几张”,但不会被提示“你缺了几张”。

10.7 成长:四个阶段,二十多次陪伴

export const STAGES: readonly GrowthStage[] = [
  { id: 1, name: '熊猫幼崽', nameEn: 'Panda Cub', atXp: 0, scale: 0.82 },
  { id: 2, name: '小熊猫', nameEn: 'Little Panda', atXp: 60, scale: 0.92 },
  { id: 3, name: '大熊猫', nameEn: 'Giant Panda', atXp: 160, scale: 1 },
  { id: 4, name: '竹竹的小屋', nameEn: "Bamboo's Home", atXp: 300, scale: 1.08 },
];

一次任务约 10–14 成长值,所以到第四阶段大约是二十多次陪伴。scale 直接喂给小屋与装扮页的 PandaBuddy:成长值变化最直观的表现不是数字,而是竹竹在房间里一点点变大。stageForXp() 取“已达到的最高阶段”,nextStageForXp() 到顶返回 null,xpToNextStage() 到顶返回 0。另有 levelForXp():每 20 点成长值升一级,只用于展示阶段内部的小步子,不参与任何解锁。

阶段名同时有 name 与 nameEn,由界面按当前语言取用(第 12 章)。小屋显示“竹竹现在 · 熊猫幼崽”,再下一行是“再攒 N 点成长值,竹竹就会长大一点”;到顶后换成“竹竹已经长大啦,小屋和旅行日记都解锁了”。成长值本身用大号数字展示,但文案始终是“再攒”,不是“还差”,避免把陪伴写成任务指标。

阶段是装扮解锁的硬门槛(第 11 章),也是任务卡的软门槛:内容里有 unlockLevel 字段,任务页会显示“长大到第 N 阶段解锁”。当前十条内容的 unlockLevel 都是 1,所以实际不会锁住任何任务;这条路径留着,是给后续“更复杂的生活技能”用的,而不是现在编造门槛。

10.8 ProgressManager:所有写操作的编排点

ProgressManager 的三条纪律写在类注释里:纯逻辑在别的文件、每次改动先更新内存再落盘、读档只做一次(single-flight)。

init(): Promise<SaveData> {
  this.loading ??= (async () => {
    const now = this.clock();
    let raw: unknown;
    try { raw = await this.store.load(); } catch { raw = null; }
    let next = migrate(raw, now);
    const today = dayKey(now);
    const gap = daysBetween(next.pet.lastDecayDay, today);
    const missingDay = next.days[today] === undefined;
    if (gap > 0) {
      next = { ...next, pet: { ...next.pet, stats: decayStats(next.pet.stats, gap), lastDecayDay: today } };
    }
    next = ensureDay(next, now);
    this.data = next;
    if (gap > 0 || missingDay) {
      try { await this.store.save(next); } catch { /* 留给后续操作的 unsaved 提示 */ }
    }
    return next;
  })();
  return this.loading;
}

三个细节值得注意:

  1. 只有状态真的变过才写盘。打开一次就写一次只会白耗 IndexedDB 寿命,也增加写失败概率;
  2. 读档失败不抛给界面。catch 后走 migrate(null),本次从空档开始,界面顶部提示“存档读取失败,本次从头开始。”;
  3. loading 缓存 Promise。App.vue 挂载时会调用 progress.init(),深链直达玩法时 PlayView.vue 挂载时还会再调一次;如果两次都去读盘,后返回的旧快照可能盖掉前一次刚写入的进度。这是单测与真实深链都验证过的坑。

completeTask() 是核心链路,顺序是:确保今天有日记录 → 判断是否今天第一次完成 → 算奖励与碎片 → 算连续天数与贴纸 → 合并新的 TaskProgress / PetState / DayRecord → commit()。返回的 CompletionOutcome 同时给界面用:

export interface CompletionOutcome {
  task: TaskProgress;
  isFirstToday: boolean;
  stars: Stars;
  shoots: number;
  xp: number;
  fragments: number;
  sticker: boolean;
}

玩法的奖励 toast 就是读它:竹笋 +n、装扮碎片 +n、新贴纸。界面不自己算奖励,也不自己拼存档——这是“模板不允许自己发奖励”的同一个原则在外壳侧的镜像。

commit() 里“先换内存对象、再落盘”的顺序还有一层 Vue 侧的考虑:Pinia store 每次操作后调用 snapshot() 拿新对象,Vue 才能检测到变化并重渲染;如果原地改同一个对象,引用不变,界面可能不更新。调试桥 window.__bamboo.save 也依赖“每次都是新对象”,端到端测试才能用快照比对判断写没写进去。

addPlaySeconds() 只累加当天秒数,dismissRest() 只把 restDismissed 置真,clear() 用空档覆盖内存与磁盘。每个方法都走 commit(),而 commit() 的第一行永远是 this.data = next:即使写盘失败,孩子这一局仍然有完整反馈,警告由 store 的 unsaved 负责。

refresh() 是“回到小屋 / 切回前台”时的轻量入口:算一下今天与 lastDecayDay 差几天,跨天就衰减一次并补日记录,没跨天且日记录已在则直接返回 { decayedDays: 0 },不写盘。它和 init() 的区别是:init() 只跑一次,refresh() 可以反复跑。应用挂在平板上过夜,第二天回前台触发的是 refresh(),不会重新读档,也不会把当前内存里的进度丢掉。

10.9 四十个单测守住了什么

packages/progress/src/logic.test.ts 有 40 个用例,按 describe 分组:

分组 用例数 代表断言
日期与时段 4 本地日期键、跨年天数、早上/中午/晚上边界
五维状态 4 夹在 10–100、收益只增、衰减最多 7 天、最多两条提示
成长阶段 3 59/60、160、300 的边界,到顶无下一阶段
今日任务推荐 3 同一天同 slot 结果一致、必含当前时段、跨日会变
奖励与连续天数 5 没有 0 星、重复减半、每 3 个不同任务给碎片、断签重来
亲密度 2 分档与每日一次(第 11 章)
存档迁移 6 坏字段逐条兜底、默认墙纸、30 天裁剪
Manager 完成链路 5 首玩全份、重复减半、连续三天贴纸、第三个任务碎片、跨天衰减
Manager 亲昵/喂食/解锁/清除 6 见第 11 章
日记与每周回顾 1 七天由旧到新、缺的日子也有位置
内容层与存档层解耦 1 推荐结果不随当前时间变化

这些用例大多用固定时钟 T0 = new Date(2026, 9, 19, 9, 0, 0) 与内存 store,不依赖真实时间、不写磁盘、不需要浏览器。这正是“逻辑与渲染分离”的回报:最容易出错的数值规则,可以在毫秒级跑完 40 条。

固定时钟还有一个隐性收益:测试可以“跨天”而不真的等一天。pm.completeTask(task(), 3, T0 + DAY) 直接把时间拨到明天,连续三天的贴纸、跨两天的衰减、断签重来都能在同一个进程里验证。如果逻辑层内部直接调 Date.now(),这些用例只能靠伪造全局定时器,既慢又脆。

10.10 当前口径与几个有意的取舍

  • 同一天重复玩仍然有收益:奖励减半、状态收益减半但每项至少 +1、成长值至少 +3。孩子想再玩一次刷牙,不该被系统告知“今天没奖励了”;
  • 日记录去重:completed 是任务 id 数组,重复完成不会追加第二次,所以“今天完成了几件不同的事”是准的;完成次数另记在 tasks[id].completions;
  • 最高星级只增不减:这次失手拿一颗,不会把上次的三颗覆盖掉;
  • 30 天裁剪按日期键字典序:YYYY-MM-DD 的字典序就是时间序,不需要解析日期;
  • 没有“补签”:断签只是重新数,不卖补签卡,也没有任何付费入口;
  • 解锁等级只做门槛不做消耗:达到阶段即可解锁,竹笋与碎片才是消耗品;当前内容全部是阶段 1,门槛逻辑保留但不会拦住新玩家;
  • 推荐不读存档:它只看日期、时段与内容表,因此小屋与任务页可以用同一份纯函数,测试也不需要先造一份存档。

10.11 小结

进度系统把“孩子今天做了一件小事”翻译成一组稳定、可解释、不制造焦虑的数字:

  • 存档是三层纯数据,迁移逐字段兜底,未来版本才整体清空;
  • 推荐由日期与时段确定,同一天不会变,三个时段都有得选;
  • 状态只增不减、跨天轻衰减、下限 10、低于 40 只给一句温柔提示;
  • 星星最低 1 颗,重复玩仍有收益,连续 3 天得贴纸,断签不清数据;
  • 成长四阶段,300 点解锁“竹竹的小屋”,阶段同时是装扮的解锁门槛;
  • ProgressManager 是唯一编排点,读档 single-flight,写盘失败不打断反馈。

下一章从“数字”转向“关系”:亲密度如何累积、十九件装扮如何解锁与穿上、日记怎样把一天串成故事,以及家长页如何在保护孩子的前提下把记录交给家长。


← 上一章 目录 下一章 →