玄学放置 · 项目初始化与核心组件封装
项目背景
「玄学放置」是一个以学科为主题的网页放置(挂机)游戏,灵感参考了银河牛牛放置那种暗色科技风的画面表达。第一阶段的目标很克制:把整体框架搭起来,留出后续装备、商店、副本等模块的接入位,并保证每一步都能在浏览器里跑起来。
技术栈选了最直接的一套组合:Vue 3 + Vite + Pinia,纯前端可运行,所有数据本地 Mock,后端先做占位。这样既能快速看到效果,又能避免一上来就把架构铺得太大、最后一个模块都没做完。
整体布局
主页分成清晰的三块:
- 左侧侧栏(学科导航)
- 右侧主体(当前学科面板)
- 底部公屏聊天
ASCII 示意如下:
┌──────────────────────────────────────┐
│ Header │
├──────┬───────────────────────────────┤
│ │ │
│ 侧栏 │ 学科面板 │
│ │ │
├──────┴───────────────────────────────┤
│ 公屏聊天 │
└──────────────────────────────────────┘之所以一开始就把公屏放进版面,是因为放置游戏天然有"挂着不动"的体验空白期,公屏可以帮忙营造"很多人在线"的氛围感,哪怕这一阶段它只是 mock 推送。
技术选型
| 项 | 选择 | 理由 |
|---|---|---|
| 框架 | Vue 3 + <script setup> | 心智模型最轻 |
| 构建 | Vite 5 | 启动快、配置短 |
| 状态管理 | Pinia | 官方推荐,复用而非自研 |
| 路由 | 暂不引入 vue-router | 单页就够,避免过度设计 |
| 样式 | 原生 CSS + CSS 变量 | 暗色科技风,不想要 UI 库管理感 |
| 数据 | 本地 ES Module + 定时器模拟挂机 | 零依赖,后续切换到真实接口几乎无成本 |
后端目录留了一个 server/README.md 做占位,写明后续用 Node 接管。当 mock 切换到真实接口时,前端只动 mock/ 模块即可,stores/ 内的逻辑不变。
目录组织
前后端同级双目录:
metaphysics/
├── frontend/
│ └── src/
│ ├── components/ 通用组件
│ ├── views/ 页面级组件
│ ├── stores/ Pinia 状态
│ ├── mock/ 假数据
│ └── styles/ 全局样式与变量
└── server/ Node 后端占位mock/ 是这次设计里比较关键的一层。它和 stores/ 同级,专门承载"未来要从接口拿"的数据。这样真接入后端时,只需要把 mock/subjects.js 改成 axios 调用,store 不用改、组件也不用改。
核心组件一:ProgressBar
放置游戏满屏都是进度条,先把它封成稳定的通用组件。
Props 契约:
percent: Number0-100(必填)label: String任务名/描述status: 'idle' | 'running' | 'done'color: String自定义填充色(覆盖默认霓虹渐变)height: Number进度槽高度showText: Boolean是否显示百分比文本
进度条的视觉关键是「正在运行」的感知。我做了三件事:
- 进度槽底色用主题边框色,填充层是青→紫的霓虹渐变
running状态下叠一层@keyframes shimmer流光动画,营造"任务在跑"的感觉done状态切换为金粉渐变,提示玩家"刚完成"
<ProgressBar
:percent="task.percent"
:label="task.name"
status="running"
:color="subject.color"
:height="14"
/>percent 在组件内做了 0-100 的钳制,避免上游传脏数据时样式溢出;填充宽度用 transition: width 180ms linear 平滑过渡,挡掉定时器抖动带来的抽搐感。
核心组件二:ChatPanel
公屏聊天本质上是一个「消息列表 + 输入框」组合,但要做到稳定复用,我刻意保持它不直接耦合 store,只接收 props、抛出事件。
Props / Emits:
messages: Array<{id, user, content, time, type?}>currentUser: String@send(content: string)
这样 ChatPanel 就是一个纯展示层,谁都可以拿去用,不绑死游戏的 chat store。后续如果要做"私聊面板"或"系统通告流",复制一份数据源即可。
几个细节:
- 新消息进入时通过
watch+nextTick自动滚到底 - 输入框用
<textarea>,回车发送、Shift+回车换行 - 不同
type(system / user / self)使用不同色块,自身消息用青色高亮,系统消息用金色
状态管理:subjects 与 chat
两个 Pinia store 各司其职。
stores/subjects.js 维护学科列表与挂机进度。它的 tickProgress(dt) 接受一个时间差(毫秒)参数:
tickProgress(dt) {
for (const subject of this.list) {
const task = subject.tasks.find(t => t.id === subject.currentTaskId)
subject.progressMs += dt
while (subject.progressMs >= task.durationMs) {
subject.progressMs -= task.durationMs
this._grantExp(subject, task.expReward)
}
}
}这里有一个故意的设计:用累计毫秒推进,而不是直接累加百分比。原因是 setInterval 在浏览器后台标签页里会被节流,真实间隔可能远大于设定值。如果按"每次推进 1%"的固定步长,挂着就慢、回到前台就突然跳;按真实经过时间结算就不会有这个问题。
stores/chat.js 维护消息列表与一条 mock 推送链:每隔 6-12 秒随机推一条系统/玩家消息,用 setTimeout 自递归实现"间隔抖动",比 setInterval 更接近真实多人聊天节奏。
挂机推进的精度细节
App.vue 顶层启动了一个 200ms 的全局定时器:
let lastTickAt = Date.now()
tickTimer = setInterval(() => {
const now = Date.now()
const dt = now - lastTickAt
lastTickAt = now
subjectsStore.tickProgress(dt)
}, 200)200ms 是平衡过的:太频繁(如 16ms)会增加无意义的渲染压力,太稀疏(如 1s)会让进度条看起来不流畅。配合 transition: width 180ms 的 CSS 过渡,整体观感是连续推进。
tickProgress 内部用 while 循环结算,能正确处理「单次 dt 横跨多个任务循环」的极端场景——比如玩家切到后台一分钟再回来,进度条会一次性补完该补的次数。
收获与后续
这一阶段的关键决策不在技术选型,而在层次切分:
mock/与stores/分开,让数据来源切换零成本components/里的通用件保持纯接口(props in / emits out),不耦合具体 storeviews/只负责装配与编排,不写复杂逻辑
后续要加装备系统、商店、副本,都可以在不动通用组件的前提下完成。下一步会先解决一个观感问题:emoji 学科图标看起来太"应付",要换成像素艺术。