第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() 的检查顺序是固定的:

  1. 物件不存在 → missing;
  2. 已经拥有(含免费自带)→ owned;
  3. 当前成长阶段低于 unlockLevel → level;
  4. 竹笋不够 → noShoots;
  5. 碎片不够 → 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('/') 弹回小屋。这个坑在端到端测试里表现为“长按后家长页闪一下就不见了”,很难靠肉眼定位。

家长页只回答四件事:

  1. 安全边界:三行 ✅——“不联网、不采集儿童个人信息”“无广告、无内购、无抽卡”“存档只在本机,可随时清除”,开头还有一段完整说明;
  2. 最近七天:weekCompletedCount() 统计完成件数;下面列出这周练过的习惯,按第一次出现的顺序去重,来源是每条任务的 habit 字段(例如“早晚都要刷牙”“饭前便后要洗手”“玩具玩完要收好”);
  3. 声音开关:与顶栏、玩法页共用同一个 settings.muted,读写 localStorage 的 bamboo.muted('1' 静音 / '0' 有声音),刷新后保持;
  4. 清除本机存档:长按 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 秒把门,只展示本机记录、声音开关与清档,不联网、不采集。

下一章处理贯穿这三层的一条横切线:中英文双语。界面文案、任务内容、关卡脚本与语音各自怎么组织,为什么游戏代码完全不知道语言的存在,以及漏翻如何在构建期被拦住。


← 上一章 目录 下一章 →