Skip to content

玄学放置 · 项目初始化与核心组件封装

项目背景

「玄学放置」是一个以学科为主题的网页放置(挂机)游戏,灵感参考了银河牛牛放置那种暗色科技风的画面表达。第一阶段的目标很克制:把整体框架搭起来,留出后续装备、商店、副本等模块的接入位,并保证每一步都能在浏览器里跑起来。

技术栈选了最直接的一套组合: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: Number 0-100(必填)
  • label: String 任务名/描述
  • status: 'idle' | 'running' | 'done'
  • color: String 自定义填充色(覆盖默认霓虹渐变)
  • height: Number 进度槽高度
  • showText: Boolean 是否显示百分比文本

进度条的视觉关键是「正在运行」的感知。我做了三件事:

  1. 进度槽底色用主题边框色,填充层是青→紫的霓虹渐变
  2. running 状态下叠一层 @keyframes shimmer 流光动画,营造"任务在跑"的感觉
  3. done 状态切换为金粉渐变,提示玩家"刚完成"
vue
<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) 接受一个时间差(毫秒)参数:

js
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 的全局定时器:

js
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),不耦合具体 store
  • views/ 只负责装配与编排,不写复杂逻辑

后续要加装备系统、商店、副本,都可以在不动通用组件的前提下完成。下一步会先解决一个观感问题:emoji 学科图标看起来太"应付",要换成像素艺术。