Skip to content

玄学放置 · 给任务卡一个呼吸的角落

起因 / 背景

任务卡一直长得像表单。每一个学科面板里几个并排的方块,头部写任务名,下面挤着耗时和经验,running 状态多一行进度文字。功能完整,但放在一起就是一片"灰白底+小字徽章"的均质感,扫一眼下来眼睛找不到落脚点——尤其当画面其他地方(学科图标、装备槽、人物剪影)都在用像素艺术发声的时候,任务卡的沉默就显得格外突兀。

想给它加点视觉锚点。最直接的思路是"在卡片里塞个图标"。但这里有个尴尬的现实:每个任务的专属图标还没设计。当前的任务名(词句摘抄、修辞练习、一元二次方程……)只是占位文案,连任务体系本身都还在迭代。如果先去画几十张专属像素图标,设计一旦推翻,所有图标全废。

所以这次的目标其实是双轨:既要现在就有视觉表达,又不能锁死未来的设计自由度。这两个看似矛盾的要求,最后落在了"背景水印"这种克制的表达方式上。

关键决策

水印而不是前置图标

第一反应是把图标放在卡片左上角,像普通列表项那样"图标+文字"。但很快发现这种布局的代价:它会把图标抬到和任务名平等的视觉权重,一旦图标本身美感不够(占位图标天然就不够),整张卡片的气质会被它拉低。而且占位图标只有一种,所有任务用同一张图,反而显得偷懒。

换成"背景水印"思路就完全不同了。水印放在卡片右下角、做适度溢出、压低透明度到 0.1——它承担装饰功能但不参与信息传达。所有任务用同一张占位图,反而像"印在卡片纸上的暗纹",变成了一种统一的设计语言,而不是"图标偷懒"。等未来某个任务图标定下来,把它替换进来,水印形态变了但位置和透明度规则不变,体验是连续的。

字段先开,数据慢慢填

为了支持"未来按任务替换图标",在 TaskDef 上加了一个可选字段:

ts
export interface TaskDef {
  id: string
  name: string
  // ...
  iconKey?: string  // 任务专属像素图标 key,未指定时用通用占位
}

可选,意味着不用一次性给所有任务都补上 iconKey,可以一个一个来。组件层用 task.iconKey ?? 'notebook' 兜底,任务图标到位前所有卡片都共享 notebook 占位,看起来仍然一致。这是典型的"先开通道、后填内容"——数据模型的扩展点提前留出,实际填充按设计节奏来,设计和开发互相不阻塞。

顺手把多帧动画基础设施埋下去

水印这个东西有意思,它不像功能性图标,需要"看清楚",反而需要"轻微地动起来"——running 状态下让它缓慢浮动一下,会自然传达"任务在进行中"的呼吸感。用 CSS keyframes 给水印做位移动画当然可以,但更想让 PixelIcon 本身就能播放多帧动画——这样以后做"宝箱开启"、"收获飘字"这些精致效果时,直接定义多帧字符画就行,不用再到处写 CSS 动画。

所以这次顺便给 PixelIcon 扩展了多帧能力,但保持完全向后兼容:旧的单帧图标定义不动,只在需要动画的图标里多写一个 frames 数组。

实现要点

多帧扩展不动旧调用

PixelIconDef 加了两个可选字段:

ts
export interface PixelIconDef {
  palette: Palette
  art: string[]            // 单帧(原有)
  frames?: string[][]      // 多帧动画,长度 ≥ 2 时自动循环
  fps?: number             // 帧率,默认 6
}

为什么不直接把 art 改成 string[][] 把所有图标都"多帧化"?因为这样会把上百张已有的单帧图标全部破坏。frames 作为可选字段,定义时只为需要动画的图标多写一段,旧定义零修改。

解析层同步加了对称的 parseIconFrames,保留 parseIcon 给单帧场景用。组件内部统一走 parseIconFrames——单帧图标返回长度为 1 的数组,多帧返回多个。这样组件代码不用区分"动画图标"和"静态图标",一套循环逻辑覆盖所有情况。

requestAnimationFrame 的"间隔节流"

PixelIcon 内部的播放循环用 rAF 而不是 setInterval。原因是 setInterval 在标签页失焦时不会暂停,会持续触发响应式更新但用户根本看不到——浪费 CPU。rAF 在标签页失焦时由浏览器自动节流到极低频率,等于免费拿到了"不可见时暂停"的优化。

但 rAF 默认按显示器刷新率触发(60Hz),像素动画通常只想跑 6Hz 左右。所以循环里要做一层时间节流:

ts
function tick(t: number): void {
  if (!lastFrameTime) lastFrameTime = t
  const interval = 1000 / effectiveFps.value
  if (t - lastFrameTime >= interval) {
    frameIndex.value = (frameIndex.value + 1) % frames.value.length
    lastFrameTime = t
  }
  rafId = requestAnimationFrame(tick)
}

每次 rAF 回调拿到时间戳,只在累积间隔超过目标帧率时才推进帧索引。这种写法保留了 rAF 的"标签页感知",又能精确控制帧率。

状态分层的水印

水印不是一种状态,而是五种状态的连续表达。idle 静态、hover 微动、running 浮动 + glow、waiting 略亮、locked 灰度。这些状态组合通过给父级 .task-card 加 modifier 类、子级 .task-card__watermark 在 modifier 选择器下覆写来实现:

scss
.task-card {
  &__watermark {
    position: absolute;
    right: -10px;
    bottom: -10px;
    opacity: 0.1;
    transform: rotate(-8deg);
    transition: all 240ms ease-out;
    pointer-events: none;
    z-index: 0;
  }

  &--running .task-card__watermark {
    opacity: 0.22;
    animation: watermark-float 3.6s ease-in-out infinite;
  }

  &--locked .task-card__watermark {
    filter: grayscale(1);
    opacity: 0.08;
  }
  // ...
}

CSS 滤镜让 locked 任务的水印自动失色,不需要额外切图也不需要 JS 介入,这种"用 CSS filter 做状态视觉差异"的小技巧在像素艺术里很好用——像素图天生色彩有限,grayscale 后效果反而更鲜明。

注意事项 / 踩到的坑

水印必须 pointer-events: none

水印是绝对定位的子元素,默认会拦截鼠标事件。任务卡靠 el-popover 包裹,点击卡片才触发气泡,如果水印不放掉点击,会出现"点到水印那块区域气泡不弹"的诡异 bug。pointer-events: none 一行解决,但这种东西不写一遍记不住,下次做覆盖层装饰还会再踩。

z-index 必须显式声明

把水印放在 DOM 的第一个子节点 + z-index: 0,把所有内容元素显式标 z-index: 1。原本以为后写的 DOM 自然在上层,但 position: absolute 一旦介入,堆叠上下文就开始按 z-index 来排,不显式声明会出现内容被水印盖住的随机情况。养成习惯:只要某层用了绝对定位,堆叠层级就要显式标号

卸载时必须 cancel rAF

PixelIcon 在播放动画时会持续注册 rAF 回调。如果组件卸载时不调 cancelAnimationFrame,回调会继续触发响应式 ref 更新——而组件已经销毁了,Vue 会有警告,严重时会因为访问已销毁的响应式对象而报错。onUnmounted(stopAnimation) 必须写,这是 rAF 类组件的标配。

收获 / 总结

这次改动表面是个 UI 美化,但里面有两个比"加图标"更深的设计动作:用占位字段解耦设计和开发的进度,以及给视觉效果体系埋下动画基础设施

占位字段那一招在做内容驱动的项目里特别有用。当数据结构和数据内容由不同节奏推进时(数据结构跟着架构走、数据内容跟着设计走),用一个 iconKey?: string 把"将来可以换"的承诺写进类型,实际值什么时候补、补什么,完全不影响代码运行。这是一种"软扩展点"——比硬编码灵活、比配置中心轻量,适合早期高频迭代的项目。

多帧动画基础设施这一步则是预先布局。当前只有水印这一个用例,但有了 frames + fps 的能力,以后任何"宝箱抖动"、"金币翻转"、"图标待机摆动"都不用再写 CSS keyframes,直接在图标定义里多画几帧就行。一次扩展、N 次复用,这种基础设施投资在像素艺术项目里回报极高——像素动画的"动起来"成本本来就低,工具到位后这种动效会成为整个游戏的视觉语言一部分。

下一步真正能让任务卡变得更生动的,是把"任务专属图标"逐个画出来。这件事得跟随任务体系的设计节奏走——而任务体系本身,正在朝"派系化"的方向重新设计。下一篇大概会聊到这条线:文科生、理科生、玄学派……以及把"学科"重新理解为"人类掌握真实世界的不同维度"之后,游戏内容能怎么展开。