第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() 的顺序是:
- 不是对象 → 直接返回空档;
- 版本号高于当前 → 视为“来自未来的存档”,整体清空(旧版本读不懂新字段,继续用只会写坏数据);
- 版本号低于当前 → 逐级调用迁移函数,缺一步就停下;
- 对
pet/tasks/days逐字段体检; - 确保今天有一条日记录,再裁剪到 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;
}
三个细节值得注意:
- 只有状态真的变过才写盘。打开一次就写一次只会白耗 IndexedDB 寿命,也增加写失败概率;
- 读档失败不抛给界面。
catch后走migrate(null),本次从空档开始,界面顶部提示“存档读取失败,本次从头开始。”; 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,写盘失败不打断反馈。
下一章从“数字”转向“关系”:亲密度如何累积、十九件装扮如何解锁与穿上、日记怎样把一天串成故事,以及家长页如何在保护孩子的前提下把记录交给家长。