第11章 - 亲密互动装扮日记与家长页
第 10 章讲的是“做任务 → 拿奖励”的数值闭环;这一章讲闭环之外的三件事:孩子和竹竹之间的关系(亲密度)、孩子对竹竹的投入(装扮与小窝)、以及孩子一天的故事如何被记录与交给家长(日记、家长页)。它们共用同一份存档,却回答不同的问题:亲密度回答“我们有多熟”,装扮回答“我把它变成了什么样”,日记回答“今天发生了什么”。
11.1 亲密度:五档关系与每日一次的小动作
亲密度在这款产品里不是“好感度数值”,而是“每天有没有亲近一下”的记录。它不参与任务解锁,也不影响奖励倍率,唯一的作用是让竹竹对小动作的反应逐档变化——从招手,到害羞捂脸,到转圈圈,再到击掌跳与比心。孩子看到的不是一条涨得更快的进度条,而是一只越来越熟的熊猫。
亲密度是三个固定动作的累积:摸头、抱抱、击掌。每个动作每天各能做一次,不消耗竹笋,也不受其他状态限制:
// packages/progress/src/intimacy.ts
export const CARE_POINTS: Record<CareAction, number> = { pat: 1, hug: 2, highfive: 2 };
export const CARE_MOOD: Record<CareAction, number> = { pat: 3, hug: 5, highfive: 4 };
抱抱给的情绪收益最高(+5 心情、+2 亲密度),击掌次之,摸头最轻。三个动作都做满,一天最多 +5 亲密度、+12 心情。竹笋不是亲密的门槛:孩子没有竹笋时仍然可以摸头、抱抱、击掌。
五档关系写在 INTIMACY_STEPS:
| 档位 | 所需亲密度 | 名称 | 解锁的小动作 |
|---|---|---|---|
| 1 | 0 | 初次见面 | 招手 |
| 2 | 5 | 好朋友 | 害羞捂脸 |
| 3 | 12 | 贴心伙伴 | 转圈圈 |
| 4 | 22 | 形影不离 | 击掌跳 |
| 5 | 35 | 最好的朋友 | 比心 |
intimacyLevel(points) 返回当前档位、中英文名、小动作与“下一档还差多少”(到顶 nextAt 为 null);unlockedGestures(points) 返回已经解锁的动作名数组。它们都是纯函数,非法输入按 0 处理,不会把界面弄崩。
“每天各一次”由 careAvailable() 判断:
export function careAvailable(
careDays: Partial<Record<CareAction, string>>,
action: CareAction,
today: string,
): boolean {
return careDays[action] !== today;
}
careDays 存的是“这个动作上次做是哪一天”,不是布尔值。跨天后它自然不再等于今天,不需要定时重置,也不需要额外的“每日任务刷新”逻辑。这个设计让存档迁移也简单:只要日期格式合法,值就是可用的。
11.2 小屋里的亲近:一个按钮、一句“今天已经做过啦”
小屋的亲近区只有四个按钮:摸头、抱抱、击掌、喂竹笋。前三个由 CARE_ACTIONS 驱动,做完后按钮禁用,文案换成“今天已经做过啦”:
// apps/site/src/views/HomeView.vue(节选)
async function doCare(action: CareAction): Promise<void> {
if (!progress.careAvailable[action]) return;
const outcome = await progress.care(action);
if (outcome?.ok) {
careFlash.value = t('care.level', { n: intimacyLevel(progress.pet.intimacy).level });
}
}
成功后的反馈不是弹窗,而是一行字:“亲密度等级 3”。它不打断孩子,也顺便告诉他“关系又近了一点”。按钮的 disabled 状态用 disabled:opacity-50,但仍然保留原来的图标与位置——孩子能看到“这个还在,只是今天做过了”,而不是按钮凭空消失。
管理器的 care() 负责结算:
async care(action: CareAction, now = this.clock()): Promise<CareOutcome> {
const today = dayKey(now);
const data = ensureDay(this.data, now);
const day = data.days[today]!;
if (data.pet.careDays[action] === today) {
return { ok: false, reason: 'already', action, intimacy: data.pet.intimacy,
allDone: day.care.length >= 3 };
}
const pet = {
...data.pet,
intimacy: data.pet.intimacy + CARE_POINTS[action],
stats: applyStatDelta(data.pet.stats, { mood: CARE_MOOD[action] }),
careDays: { ...data.pet.careDays, [action]: today },
};
// …更新当天 care 数组、commit()
}
注意 allDone:三个动作都做过之后为真。它是留给界面的扩展点,当前版本还没有消费它——不改变奖励,也不构成“全勤”。
喂竹笋是另一种交互:不限次数,但消耗资源。
async feed(now = this.clock()): Promise<FeedOutcome> {
if (this.data.pet.shoots < 1) {
return { ok: false, reason: 'noShoots', feeds: this.data.pet.feeds };
}
const pet = {
...this.data.pet,
shoots: this.data.pet.shoots - 1,
feeds: this.data.pet.feeds + 1,
intimacy: this.data.pet.intimacy + 1,
stats: applyStatDelta(this.data.pet.stats, { fullness: 12, mood: 3 }),
};
// …commit()
}
一根竹笋换 +12 饱腹、+3 心情、+1 亲密度。孩子可以把今天赚的竹笋全喂掉,也可以攒着买装扮;两种选择都合理,没有“最优解”压力。没有竹笋时按钮禁用,文案从“喂竹笋”变成“竹竹吃饱啦”。
为什么三个亲昵动作要限制为每天一次,而喂竹笋不限?因为前者是“关系仪式”,后者是“资源使用”。仪式重复太多次会失去分量,也容易变成刷亲密度的机械点击;资源使用则应该让孩子自己决定节奏。这个区分让亲密度涨得慢而稳定:三个动作做满一天 +5,五档(35 点)大约需要一周多的持续陪伴,和成长阶段的时间尺度大致对齐。
11.3 装扮目录:十二件衣服与七件小窝物件
目录在 packages/progress/src/catalog.ts,全部是常量,没有抽卡池、没有随机、没有付费入口。槽位分两组:
export const CLOTHING_SLOTS: readonly ClothingSlot[] = ['hat', 'bib', 'outfit', 'backpack', 'shoes'];
export const ROOM_SLOTS: readonly RoomSlot[] = ['wallpaper', 'bed', 'shelf', 'toyCorner', 'rug'];
十二件衣服按部位分布:帽子 3 件、围兜 3 件、衣服 2 件、小书包 2 件、小鞋子 2 件;七件小窝物件是墙纸 2 件、地毯 2 件、小床 1 件、书架 1 件、玩具角 1 件。每件物品都有同一组字段:
export interface CatalogItem {
id: string;
slot: CatalogSlot;
name: string;
nameEn: string;
emoji: string;
costShoots: number;
costFragments: number;
unlockLevel: 1 | 2 | 3 | 4;
default?: boolean;
}
几张有代表性的价格:
| 物件 | 部位 | 竹笋 | 碎片 | 阶段 | 备注 |
|---|---|---|---|---|---|
竹叶帽 hat-bamboo |
帽子 | 8 | 0 | 1 | 最便宜的帽子 |
星星围兜 bib-star |
围兜 | 6 | 0 | 1 | 比帽子更早买得起 |
小皇冠 hat-crown |
帽子 | 16 | 1 | 3 | 阶段 3 的帽子 |
星星睡衣 outfit-pjs |
衣服 | 14 | 1 | 2 | 阶段 2 的第一件衣服 |
小雨衣 outfit-rain |
衣服 | 18 | 2 | 3 | 衣服里最贵的一件 |
云朵床 room-bed-cloud |
小床 | 18 | 2 | 2 | 小窝里最有存在感 |
竹林墙纸 room-wall-forest |
墙纸 | 20 | 0 | 3 | 只花竹笋,不花碎片 |
免费自带的只有两件:米白墙 room-wall-cream 与竹绿地毯 room-rug-green。defaultEquipment() 在空档里就把它们放进 pet.room,保证新玩家的小屋不是一片空白;ownsItem() 把“免费自带”与“已解锁”合并成一个判断:
export function ownsItem(pet: Pick<PetState, 'unlocked'>, item: CatalogItem): boolean {
return item.default === true || pet.unlocked.includes(item.id);
}
价格与阶段的组合让“再玩几天就能得到”变得可计算:孩子看到“竹笋 12 / 阶段 2”,知道要么攒,要么先长大。没有折扣倒计时,也没有“限时”。
这套目录刻意没有抽卡。抽卡的乐趣来自不确定性与稀有度,而这两样东西对 4–8 岁的孩子意味着“想要却得不到”的挫败,也意味着家长要面对“再抽一次”的请求。这里改成“看得见的价格 + 明确的阶段”:每一次完成任务的竹笋都会进入同一个钱包,攒够就能买,买不到也知道差多少。
11.4 解锁与穿脱:先看阶段,再扣资源
ProgressManager.unlock() 的检查顺序是固定的:
- 物件不存在 →
missing; - 已经拥有(含免费自带)→
owned; - 当前成长阶段低于
unlockLevel→level; - 竹笋不够 →
noShoots; - 碎片不够 →
noFragments。
if (stageForXp(this.data.pet.xp).id < item.unlockLevel) {
return { ok: false, reason: 'level', item };
}
if (this.data.pet.shoots < item.costShoots) {
return { ok: false, reason: 'noShoots', item };
}
if (this.data.pet.fragments < item.costFragments) {
return { ok: false, reason: 'noFragments', item };
}
先判阶段、再判资源,是为了让提示更准确:竹笋够了但阶段不够,应该告诉孩子“竹竹长大到第 3 阶段才能解锁”,而不是“竹笋不够”。解锁成功后会自动穿上(withItemEquipped),少一步操作;否则孩子刚花完资源,还要再点一次“穿上”,很容易以为没生效。
阶段门槛还有一个产品作用:它把“长大”变成可感知的权益。如果所有装扮都能用竹笋买到,成长值就只剩一个数字;现在高级装扮会明确写着“阶段 3”,孩子在小屋里看到“再攒 40 点成长值”,会自然把“继续做小事”与“解锁新衣服”连起来,而不需要任何广告或推送去提醒。
穿脱由 equip() 处理,衣服与小窝的行为不同:
- 衣服:同一部位再点一次是脱下来(从
equipped删掉),点另一件是替换; - 小窝:点已穿的物件不会“脱掉”,因为墙纸、床、地毯不能消失;点同部位的另一个物件就是替换。
穿脱只接受已拥有的物件,否则返回 locked。存档体检也会再守一道:equipped / room 里的每个 id 必须存在、部位与槽位匹配、并且确实在 unlocked 里,否则丢弃。这样即使存档被手改,界面也不会出现“穿了一件不存在的衣服”。
11.5 DressView:两个 tab、一张试穿预览、按部位分组
装扮页的结构很简单:余额与阶段 → 试穿预览 → 衣服/小窝两个 tab → 按部位分组的卡片列表。
预览区把 RoomScene 与 PandaBuddy 叠在一起:
<!-- apps/site/src/views/DressView.vue(节选) -->
<div data-testid="dress-preview" class="relative mt-3 h-56 overflow-hidden rounded-3xl border-2 border-cream bg-paper">
<RoomScene :room="progress.pet.room" />
<div class="absolute inset-0 flex items-end justify-center">
<div class="h-52 w-44">
<PandaBuddy
:equipped="progress.pet.equipped"
:stage-scale="progress.stage.scale"
mood="happy"
/>
</div>
</div>
<span class="absolute left-2 top-2 ...">竹竹试穿</span>
</div>
两个组件都是纯 SVG 几何绘制:PandaBuddy.vue 三百余行,画出头、耳、眼斑、鼻子、嘴、腮红、四肢与各种衣服;RoomScene.vue 一百四十余行,画出墙、窗、地板、地毯、床、书架、玩具角与盆栽。换装不需要下载任何图片,也不需要重新加载场景,改一个 prop 就重绘。
分组由 dressGroups(tab, t) 生成:
export function dressGroups(tab: DressTab, tr: (key: MessageKey) => string): DressGroup[] {
if (tab === 'clothes') {
return CLOTHING_SLOTS.map((slot) => ({
slot, title: tr(CLOTHING_SLOT_KEYS[slot]), items: itemsForSlot(slot),
})).filter((group) => group.items.length > 0);
}
return ROOM_SLOTS.map((slot) => ({
slot, title: tr(ROOM_SLOT_KEYS[slot]), items: itemsForSlot(slot),
})).filter((group) => group.items.length > 0);
}
空组直接不显示。目录以后只会加东西,现在没必要给空分类占位;少一个空标题,孩子就少一次“点进去什么都没有”的困惑。
卡片上的动作文案由 actionLabel() 决定:没拥有是“解锁”,拥有且当前穿着是“换下来”(衣服)或“穿着”(小窝),拥有但没穿是“穿上”。颜色也分三态:金色待解锁、奶白已穿着、竹绿可穿上。所有按钮 min-h-12,卡片两列网格,在 375 px 宽的屏幕上仍有足够的点按面积。
解锁失败时给的不是错误码,而是一句人话:
function unlockMessage(reason: string | undefined, item: CatalogItem): string {
if (reason === 'level') return t('dress.needLevel', { n: item.unlockLevel });
if (reason === 'noShoots') return t('dress.noShoots');
if (reason === 'noFragments') return t('dress.noFragments');
return t('dress.locked');
}
对应的中文是“竹竹长大到第 N 阶段才能解锁”“竹笋还不够,再帮竹竹做一件事吧”“装扮碎片还不够,接着玩就有啦”。失败提示以 2.6 秒的 toast 出现,不弹模态框,不打断继续浏览。
外观映射集中在 logic/pandaLook.ts:moodFromStats() 按“最需要被照顾的那一项”给一个表情(困了、饿了、脏了、难过、开心),faceFor() 把表情拆成眼睛/嘴/眼泪/泥点四个布尔量,clothingColor() 按 id 前缀给低饱和颜色,hatKind() 把帽子分成竹叶帽、小草帽、小皇冠三种画法。颜色全部是低饱和的奶白、竹绿、麦金、雾紫、暖红;没有荧光色,也没有“稀有度光效”。
表情规则有一条边界值得说明:只给一个表情,不做叠加。判定按固定优先级走:精力低于 40 就是困,否则饱腹低于 40 就是饿,否则清洁低于 40 就是脏,否则心情低于 40 就是难过;都不低于 40 才是开心。孩子一次只需要读懂一种情绪,四种同时出现只会让他不知道先做什么。
11.6 日记:把一天串成“竹竹的一天”
日记的数据源是 packages/progress/src/diary.ts,四个函数都很薄:
export function todayRecord(save: SaveData, now: number): DayRecord {
return save.days[dayKey(now)] ?? emptyDay(dayKey(now));
}
/** 最近七天(含今天),由旧到新:日记页与家长页共用同一份口径 */
export function weekSummary(save: SaveData, now: number): WeekDay[] {
// …offset 从 6 到 0,缺的日子补空
}
WeekDay 带 isToday、completed、stickers、care(数量)、seconds 与 exists。缺的日子也有位置,只是全空——最近七天永远七行,孩子不会看到“日期跳了一块”。weekCompletedCount() 把七天完成数加起来,家长页的“最近七天完成了 N 件小事”就是它;todayCompletedCount() 只算今天。
日记页的“今天”区块,把今天完成过的任务逐个变成一行:
const todayLines = computed(() =>
progress.todayRecord.completed
.map((id) => getTaskSummary(id, locale.current))
.filter((task): task is TaskSummary => task !== undefined),
);
每一行的文字来自内容 JSON 的 diary 字段,例如刷牙是“今天帮竹竹刷了牙,牙齿亮晶晶。”,吃饭是“给竹竹喂了蔬菜和水果,它吃得很开心。”。它不是运行时生成的,也不是大模型写的——十条内容就是十句固定的话,可审阅、可翻译、可测试。日记页只负责按完成顺序把它们排出来。
固定文案还有一个好处:家长能预期孩子看到什么。日记不会因为某次模型调用而说出奇怪的话,也不会夹带广告或外部链接。它本质上是一份“本机活动摘要”,只是用了故事的口吻。
右上角的朗读按钮把今天所有日记行用空格拼起来,交给 platform.audio.speak(lines.join(' '), locale.current)。这解决了一个实际问题:4–8 岁的孩子还读不了长句,听是主要通道。朗读进行时按钮显示“听一听”,结束后恢复“听竹竹说”;静音时点击不做事(不是灰掉),因为孩子会一直点一个看不懂原因的禁用按钮。
贴纸册永远是十格:
// apps/site/src/logic/diary.ts
export function stickerRows(pet: PetState, tasks: readonly TaskSummary[]): StickerRow[] {
const owned = new Set<TaskId>(pet.stickers);
return tasks.map((task) => ({
taskId: task.id,
emoji: task.emoji,
title: task.title,
owned: owned.has(task.id),
}));
}
已得到的亮起来,没得到的是灰度剪影,标题显示“?”。点任意一格会在下方显示“任务名 · 已得贴纸”或“连续做 3 天,就能得到一张贴纸”。最近七天区域则用 dayKind() 决定标签:今天、昨天、或 MM/DD;每行显示完成数与小贴纸图标。
11.7 家长页:一道长按门,四件家长关心的事
家长页的入口在标题上:长按 3 秒进入。短按只是回小屋:
// apps/site/src/App.vue(节选)
function startParentHold(): void {
if (holdTimer !== null) return;
holdFired = false;
holdTimer = setTimeout(() => {
holdTimer = null;
holdFired = true;
navigate('/parent');
}, 3000);
}
function onTitleClick(): void {
if (holdFired) { holdFired = false; return; }
navigate('/');
}
holdFired 是必要的:长按结束后浏览器还会补一次 click,如果不吞掉这一次,页面会先进入家长页、再被 navigate('/') 弹回小屋。这个坑在端到端测试里表现为“长按后家长页闪一下就不见了”,很难靠肉眼定位。
家长页只回答四件事:
- 安全边界:三行 ✅——“不联网、不采集儿童个人信息”“无广告、无内购、无抽卡”“存档只在本机,可随时清除”,开头还有一段完整说明;
- 最近七天:
weekCompletedCount()统计完成件数;下面列出这周练过的习惯,按第一次出现的顺序去重,来源是每条任务的habit字段(例如“早晚都要刷牙”“饭前便后要洗手”“玩具玩完要收好”); - 声音开关:与顶栏、玩法页共用同一个
settings.muted,读写localStorage的bamboo.muted('1'静音 /'0'有声音),刷新后保持; - 清除本机存档:长按 3 秒,按钮内有一条红色进度条从左往右长满;清空后文案变成“已清除”,但停留在家长页,家长可以自己确认。
清档按钮有两个实现细节。宽度必须固定:
<button class="relative min-h-12 min-w-64 overflow-hidden whitespace-nowrap ...">
注释里写得很清楚:文案会在“清除本机存档(长按 3 秒)”与“继续按住…”之间变化,如果按钮跟着缩,按住时指针会落到按钮外触发 pointerleave,长按永远到不了 3 秒。另一个细节是 pointerdown / pointerup / pointerleave / pointercancel 四个事件都要停表,触摸到一半滑走也必须取消。
ProgressManager.clear() 不是只清内存:它构造一份空档、赋值给 data、把 loading 改成已完成的 Promise,并立刻 store.save(next)。端到端测试专门验证“清档后刷新不会变回旧进度”,以及“清档后还能正常玩一局”——清空与重新初始化是两件事,后者最容易漏。
家长页的版本号直接读 apps/site/package.json 的 version,缺省 0.0.0;不额外维护一份常量,避免发布时忘了改。
三条端到端用例把这一页的关键性质钉住:短按标题 600 ms 后仍看不到家长页;长按 3.2 秒进入后能看到本周概览;声音开关翻转后写进 bamboo.muted、刷新仍保持、再翻一次也记得;清档长按 3.2 秒后存档里 brush.completions 归零、竹笋回到 3,刷新不回潮,清档后还能再完成一局。它们同时也是回归测试:任何一次重构把长按逻辑、localStorage 键名或清档落盘改坏,都会在这里失败。
11.8 “无失败、无惩罚”如何落进界面
这一章的所有界面都在执行同一条产品约束。把它拆成可检查的条目:
| 原则 | 具体实现 |
|---|---|
| 没有失败 | 星星最低 1 颗;任务可以无限重试;没有倒计时、没有扣命、没有“游戏结束” |
| 没有惩罚文案 | 文案检查禁止“失败 / 惩罚 / 笨蛋 / 输了 / 淘汰”;做错只说“没关系,再试一次” |
| 不制造焦虑 | 状态下限 10;低状态只显示一朵小芽与一句“竹竹想刷牙啦”;提醒是邀请不是警告 |
| 不用损失驱动 | 断签只重新计数,不清数据、不提示“你断了”;贴纸不是货币 |
| 大点按区 | 按钮 min-h-12(约 48 CSS 像素起),关键入口 min-h-14;卡片整块可点 |
| 不靠颜色单独传信息 | 对/错同时有图标、文案与音效;整理箱子用形状 + 图标区分 |
| 尊重减少动态 | prefers-reduced-motion 下竹竹停止呼吸、眨眼与小 z 动画 |
| 声音可控 | 三处共用同一静音状态,静音立即停朗读;游戏仍可玩 |
| 家长可控 | 标题长按进家长页、清档长按 3 秒、存档只在本机 |
这些条目不是“设计理念”段落,而是 code review 时会逐条对照的检查项。例如看到某个新按钮只有 h-8,或者某个错误提示写了“失败”,评审就会打回。
11.9 小结
- 亲密度是三个每日动作的累积,五档解锁小动作;不消耗资源,抱抱收益最高;
- 喂竹笋不限次数但消耗竹笋,把“奖励”变成“可以花在关系上的东西”;
- 装扮是十二件衣服 + 七件小窝物件,全部用玩出来的竹笋与碎片解锁,阶段是门槛、不是付费点;
- 解锁与穿脱先判阶段再扣资源,解锁后自动穿上,存档体检再守一道;
- 日记把十条固定的
diary文案按完成顺序串起来,十个贴纸格永远都在,最近七天永远七行; - 家长页用长按 3 秒把门,只展示本机记录、声音开关与清档,不联网、不采集。
下一章处理贯穿这三层的一条横切线:中英文双语。界面文案、任务内容、关卡脚本与语音各自怎么组织,为什么游戏代码完全不知道语言的存在,以及漏翻如何在构建期被拦住。