Skip to content

玄学放置 · 任务队列的三次迭代与工程化升级

一个需求改了三次

任务队列这个功能从初稿到目前的样子,迭代了三轮:

  1. v1:全局单任务,一个学科只能挂一个任务
  2. v2 误读:以为要做"并发 3 个任务同时挂机",做完发现理解错了
  3. v3 修正:单线程顺序执行,每个任务带"目标次数",完成自动切换
  4. v4 极简化:把队列管理从顶部展开式列表收进气泡按钮,任务卡也气泡化

每次迭代都改了 store 模型,几次重构下来 store 反而越改越简洁。这篇记录这个过程,以及顺带做的工程化升级(SCSS / UnoCSS / Element Plus 接入)。

v2 → v3:从"并发"回到"顺序"

中途有一版把队列做成了"前 3 项并发执行",错误来自我对"队列"二字的本能联想——队列嘛,多个 worker 并发取任务嘛。但放置游戏的语义其实更像"任务清单":玩家排好顺序,系统按顺序一个一个执行,每个任务跑指定次数后自动切换下一项。

这是一个非常典型的"从模式记忆里调出错误模板"的失误。修正后核心模型变成:

js
queue: [
  { subjectId, taskId, targetCount, completedCount, progressMs, addedAt },
  ...
]

任意时刻只有 queue[0] 在跑,每个条目有 targetCount(正整数 N 次或 null 表示无限循环),完成 targetCount 次后从队列移除、自动切到下一项。

tickProgress 处理的几个边界

单线程顺序执行的精度推进比并发版本还麻烦一点,因为单次 dt 可能横跨多个完成 + 跨任务边界:

js
tickProgress(dt) {
  let entry = this.queue[0]
  let remaining = dt

  while (remaining > 0 && entry) {
    const subject = this.list.find(s => s.id === entry.subjectId)
    const task = subject?.tasks.find(t => t.id === entry.taskId)

    const need = task.durationMs - entry.progressMs
    if (remaining < need) {
      entry.progressMs += remaining
      remaining = 0
      break
    }

    // 本轮完成
    remaining -= need
    entry.progressMs = 0
    entry.completedCount += 1
    this._grantExp(subject, task.expReward)

    // 达到目标次数 → 移除条目,进入下一项
    if (entry.targetCount !== null && entry.completedCount >= entry.targetCount) {
      this.queue.shift()
      entry = this.queue[0]
    }
  }
}

while 循环的存在是为了处理"玩家切到后台一分钟回来"这种极端情形:单次 dt 可能足够把当前任务跑完 3 次再切到下一个任务跑 1 次。如果只用 if 处理一次完成,就会漏算或卡住。

顺序操作:上移 / 下移 / 置顶 / 移除 / 清空

这五个操作都是数组的位置操作:

js
moveUp(index)    { 与前一项交换 }
moveDown(index)  { 与后一项交换 }
moveToTop(index) { splice + unshift }
removeFromQueue(index) { splice }
clearQueue() { this.queue = [] }

故意保留的细节:调整顺序时不重置 progressMs / completedCount。即一个任务从 running → waiting 再回到 running,会从原有进度继续累加。这是把"排序"和"重启"区分开——前者是纯展示性操作,不应该惩罚玩家。

v4 极简化:从展开式列表到气泡

v3 顶部条占了整整 280px 高度,把队列每一项都列出来。看着信息量丰富,但其实很多时候玩家并不在乎"第 5 项是什么"。这个空间挤压了下面的学科面板。

参考了一些放置游戏的处理:把队列收进一个气泡按钮,需要时再展开。

新顶部条只显示三件事:

  • 当前在跑的任务(图标 + 任务名 + 进度条 + 3 / 5 次
  • 队列总数 badge
  • 一个青色胶囊「📋 队列 N」按钮,点击弹气泡
[图标] 数学  一元二次方程  3/5次  ▓▓▓▓░░░  [📋 队列 4]

队列管理操作(移动、移除、清空)全部塞进 el-popover 里。气泡用项目暗色主题:

scss
.el-popper.is-light.queue-popover {
  background: var(--bg-panel) !important;
  border: 1px solid var(--border-strong) !important;
  box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.6) !important;
}

体感上,玩家平时只看顶部条进度,需要管理队列时点一下按钮才打开抽屉式气泡,主面板视觉负担减轻一大截。

任务卡气泡化

老版本任务卡里塞了次数输入框 + 无限切换按钮 + 加入队列按钮,4 个学科 × 3 任务一屏 12 个卡,看起来非常密。

新版任务卡只显示概要(任务名 / 耗时 / 经验),点击卡片整体弹出操作气泡:

┌──────────────────────────────┐
│  📐  一元二次方程             │
│ ────────────────────────────  │
│  单次耗时        7.0s        │
│  单次经验        +12 EXP     │
│ ────────────────────────────  │
│  执行次数                     │
│  [ 5  ▲▼ ]   [ ↻ ∞ ]        │
│ ────────────────────────────  │
│  [▶ 立即开始] [⊕ 加入队列#3] │
└──────────────────────────────┘

气泡里有两个主要操作:

  • 立即开始:调用 store.startNow(...),插入队列首位并立即执行(原本在跑的任务退到 #2 等待)
  • 加入队列 #N:调用 store.enqueueTask(...),按钮文案动态显示插入位置

「立即开始」按钮用青→紫渐变填充,是项目首处主 CTA 强调,在视觉权重上明显高于次要操作。

「停止」按钮去哪了

v3 任务卡上有「从队列移除」按钮,v4 完全去掉了。原因是:

  • 任务卡变成"添加器",不该承担"管理器"职责
  • 移除操作天然属于队列气泡的范畴
  • 减少卡片上的按钮数量,让点击区域更纯粹

要停止当前任务的方式:打开队列气泡 → 点该任务行的 ✕ 按钮,或一键清空整个队列。

工程化升级:SCSS + UnoCSS + Element Plus

v3 改造时一并引入了三个工具,让样式编写更高效:

切到 pnpm

之前用的是 npm,趁这次重构直接清干净换成 pnpm(项目本身 package.json 写的就是 pnpm)。

SCSS

*.css 全部改名为 *.scss.vue 文件加 <style scoped lang="scss">。SCSS 的嵌套语法让 BEM 风格的样式写起来更直观:

scss
.task-card {
  // ...

  &--running { ... }
  &--waiting { ... }
  &__head { ... }
  &__btn {
    &--start { ... }
    &--queue { ... }
  }
}

vite.config.jsmodern-compiler API 消除 sass 1.x 的 legacy-js-api 警告:

js
css: {
  preprocessorOptions: {
    scss: { api: 'modern-compiler' }
  }
}

UnoCSS

不是为了把所有样式都改成原子化,而是为了高频组合类的复用。uno.config.js 沉淀几个项目级 shortcut:

js
shortcuts: {
  'panel-base': 'bg-bg-panel border ... rounded-[10px]',
  'flex-center': 'flex items-center justify-center',
  'flex-between': 'flex items-center justify-between',
  'icon-btn': 'w-6 h-6 rounded inline-flex items-center justify-center ...'
}

主题色直接接入 CSS 变量同源的色板,UnoCSS 类名 text-accent-cyan 和 SCSS 变量 var(--accent-cyan) 永远一致:

js
theme: {
  colors: {
    accent: {
      cyan: '#5eead4',
      purple: '#a78bfa',
      // ...
    }
  }
}

Element Plus

引入是为了三个高频组件,自己造轮子明显不划算:

  • el-input-number:次数输入(带步进按钮 + 范围限制)
  • el-popover:队列气泡 + 任务卡气泡
  • el-popconfirm:清空队列的二次确认
  • @element-plus/icons-vue:矢量图标库

要让它和暗色主题协调,做了两件事:

  1. <html class="dark"> 让 Element Plus 自动走暗色主题
  2. 写了 styles/element-overrides.scss 覆盖默认色:
scss
:root {
  --el-color-primary: #5eead4; // 与项目主青色对齐
  --el-color-danger: #f87171;
}

.el-input-number .el-input__wrapper {
  background-color: var(--bg-base) !important;
  // ...
  &.is-focus {
    box-shadow: 0 0 0 1px var(--accent-cyan) inset !important;
  }
}

这种"覆盖式接入"是组件库与设计系统协作的常见手法:用第三方组件的能力,但用自己项目的颜色。

后续可优化点

引入 Element Plus 全量后 bundle 飙到 1.17 MB(gzip 379 KB),是必须解决的问题。计划用 unplugin-auto-import + unplugin-vue-components 改成按需引入,预计能砍到 500 KB / 200 KB gzip 以内。这一步留到下一次性能优化集中做。

总结

任务队列这个功能 4 个版本下来的体感是:

  • store 模型每改一次都更简洁:v1 八个学科各自带 progress → v2 临时态前 3 项并发 → v3 全局唯一队列 + 单线程 → v4 加一个 startNow 就够了
  • UI 反而越改越克制:从展开式列表 → 气泡 → 任务卡气泡化,每次都去掉一些按钮和状态
  • 工具链投入有杠杆效应:SCSS / UnoCSS / Element Plus 让后续每次 UI 调整成本都更低

放置游戏的玩法迭代是长跑,决定速度的不是单次实现的效率,而是状态模型和样式体系的健康度。这次让我确信这个判断——v4 极简化能在一个上午完成,正是因为前几轮把数据结构和样式工具铺好了。