玄学放置 · 任务队列的三次迭代与工程化升级
一个需求改了三次
任务队列这个功能从初稿到目前的样子,迭代了三轮:
- v1:全局单任务,一个学科只能挂一个任务
- v2 误读:以为要做"并发 3 个任务同时挂机",做完发现理解错了
- v3 修正:单线程顺序执行,每个任务带"目标次数",完成自动切换
- v4 极简化:把队列管理从顶部展开式列表收进气泡按钮,任务卡也气泡化
每次迭代都改了 store 模型,几次重构下来 store 反而越改越简洁。这篇记录这个过程,以及顺带做的工程化升级(SCSS / UnoCSS / Element Plus 接入)。
v2 → v3:从"并发"回到"顺序"
中途有一版把队列做成了"前 3 项并发执行",错误来自我对"队列"二字的本能联想——队列嘛,多个 worker 并发取任务嘛。但放置游戏的语义其实更像"任务清单":玩家排好顺序,系统按顺序一个一个执行,每个任务跑指定次数后自动切换下一项。
这是一个非常典型的"从模式记忆里调出错误模板"的失误。修正后核心模型变成:
queue: [
{ subjectId, taskId, targetCount, completedCount, progressMs, addedAt },
...
]任意时刻只有 queue[0] 在跑,每个条目有 targetCount(正整数 N 次或 null 表示无限循环),完成 targetCount 次后从队列移除、自动切到下一项。
tickProgress 处理的几个边界
单线程顺序执行的精度推进比并发版本还麻烦一点,因为单次 dt 可能横跨多个完成 + 跨任务边界:
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 处理一次完成,就会漏算或卡住。
顺序操作:上移 / 下移 / 置顶 / 移除 / 清空
这五个操作都是数组的位置操作:
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 里。气泡用项目暗色主题:
.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 风格的样式写起来更直观:
.task-card {
// ...
&--running { ... }
&--waiting { ... }
&__head { ... }
&__btn {
&--start { ... }
&--queue { ... }
}
}vite.config.js 用 modern-compiler API 消除 sass 1.x 的 legacy-js-api 警告:
css: {
preprocessorOptions: {
scss: { api: 'modern-compiler' }
}
}UnoCSS
不是为了把所有样式都改成原子化,而是为了高频组合类的复用。uno.config.js 沉淀几个项目级 shortcut:
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) 永远一致:
theme: {
colors: {
accent: {
cyan: '#5eead4',
purple: '#a78bfa',
// ...
}
}
}Element Plus
引入是为了三个高频组件,自己造轮子明显不划算:
el-input-number:次数输入(带步进按钮 + 范围限制)el-popover:队列气泡 + 任务卡气泡el-popconfirm:清空队列的二次确认@element-plus/icons-vue:矢量图标库
要让它和暗色主题协调,做了两件事:
<html class="dark">让 Element Plus 自动走暗色主题- 写了
styles/element-overrides.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 极简化能在一个上午完成,正是因为前几轮把数据结构和样式工具铺好了。