Skip to content

玄学放置 · 中间插槽化与装备气泡框交互改造

起因

项目发展到这个阶段,左侧导航只承载"学科"一种菜单。但放置游戏的菜单注定会生长——市场、商店、技能树、成就、设置……每加一个菜单,中间主体区域就要展示完全不同的内容。之前 HomeView 里硬编码 <SubjectPanel />,点哪个学科都只看得到任务卡片,完全不具备扩展性。

同时,右侧装备面板的交互一直停留在"浏览器原生 tooltip"阶段——悬浮出一段纯文本提示,装备点击直接穿戴,槽位点击直接卸下。这种操作方式有两个问题:玩家很难发现"点一下就能穿",因为视觉上和普通物品格没有任何区别;一旦误点就无法撤销,比如本来只想看看属性结果手滑直接把装备卸了。

这次改造解决这两个问题:中间区域插槽化,让不同菜单类型各自承载自己的内容;装备/物品交互从悬浮提示改为点击弹出气泡框,操作放在气泡框内,给玩家一个"看清再决定"的缓冲区。

中间插槽化:MenuType 驱动动态组件

核心设计是在 subjects store 里引入 MenuTypecurrentMenuType

ts
export type MenuType = 'subject' | 'market' | 'settings'

export interface MenuItem {
  id: string
  name: string
  iconKey: string
  color: string
  type: MenuType
  level?: number
}

MenuItem 是一个统一的视图模型。学科菜单项的 type 固定为 'subject',附带 level;扩展菜单(市场、设置)的 type 对应各自的枚举值。

store 的 state 加了 currentMenuType 字段,selectMenu() 方法同时设置 currentIdcurrentMenuType,让两者始终一致:

ts
selectMenu(menuItem: MenuItem): void {
  this.currentId = menuItem.id
  this.currentMenuType = menuItem.type
}

HomeView 里的中间区域换成动态组件:

vue
<component :is="currentMainComponent" :key="store.currentId" />

currentMainComponent 根据 currentMenuType 返回不同的组件——学科返回 SubjectPanel,市场返回 MarketPanel,设置返回 SettingsPanel:key 绑定 currentId 确保切换学科时组件重建,避免数据残留。

这种模式的好处是新增菜单类型时只需要三步:给 MenuType 加一个枚举值、写一个对应的面板组件、在 computed 里加一个 case。HomeView 和 SidebarNav 不需要改动。

SidebarNav 的分组与分割

改造后的侧栏导航不再是一列扁平的学科按钮,而是分组展示:上方"学科"组展示所有学科(带等级),下方通过分割线隔开展示扩展菜单。

分组不是装饰——视觉上的分区暗示了功能上的分区:学科是核心玩法入口,市场和设置是辅助功能。玩家扫一眼就能建立"主功能在上、辅助在下"的心智模型。

isActive() 的判断逻辑也跟着变了。原来只比对 currentId,现在要同时比对 currentIdcurrentMenuType,因为不同类型菜单可能共享 id 命名空间:

ts
function isActive(item: MenuItem): boolean {
  return store.currentId === item.id && store.currentMenuType === item.type
}

装备气泡框:从 title 属性到 el-popover

原来的交互是这样的:

vue
<button
  class="bag__cell"
  :title="buildTooltip(entry.item)"
  @click="onEquip(entry)"
>

浏览器原生 title 属性有几个硬伤:延迟约 1 秒才出现,样式不可控,不支持富文本排版,移动端几乎不可用。更关键的是点击直接触发穿戴/卸下操作——没有"看清再决定"的中间态。

改造后全部换成 el-popovertrigger="click"

vue
<el-popover
  placement="left"
  :width="200"
  trigger="click"
  popper-class="item-popover"
>
  <template #reference>
    <button class="bag__cell" :class="['rarity-' + entry.item?.rarity]">
      <PixelIcon :name="entry.item.iconKey" :size="34" />
    </button>
  </template>

  <!-- 气泡内容 -->
  <div class="item-pop">
    <div class="item-pop__head">...</div>
    <div class="item-pop__stats">...</div>
    <div class="item-pop__actions">
      <button @click="equipItem(entry.itemId)">穿戴</button>
    </div>
  </div>
</el-popover>

气泡框内的结构统一为:头部(图标+名称)→ 品质标签 → 描述 → 属性词条 → 操作按钮。不同场景的操作按钮不同:

  • 装备槽:显示"卸下装备"按钮(红色,危险操作视觉提示)
  • 背包装备:显示"穿戴"按钮,已装备时变灰并文案改为"已装备"
  • 物品 tab:纯展示,显示持有数量,无操作按钮

placement 的方向根据位置选择——左侧装备槽弹出方向选 left,右侧装备槽选 right,背包统一 left。这样气泡框始终往面板外侧展开,不会遮挡主体内容。

品质文字颜色:从边框到标签

原来品质只用边框颜色+发光表达,在气泡框内部这些视觉信号失效了——框内空间紧凑,发光效果挤在一起反而显得杂。所以给气泡框内的品质标签加了一组文字颜色类:

css
.rarity-text--common    { color: #9ca3af; }
.rarity-text--uncommon  { color: #4ade80; }
.rarity-text--rare      { color: #60a5fa; }
.rarity-text--epic      { color: #a78bfa; }
.rarity-text--legendary { color: #fbbf24; }

和边框色一一对应,但在小空间里文字颜色比边框发光更容易辨识。玩家不需要盯住一个小格子看边框,直接读品质标签的颜色就行。

目录结构调整

同一轮迭代里,另一个改动是对项目目录结构做了重组。之前的组件全部平铺在 components/ 下,分不出哪些是全局通用、哪些属于特定视图。

调整后的结构:

src/
  components/          ← 全局通用组件
    PixelIcon/index.vue
    ProgressBar/index.vue
  views/
    Home/
      index.vue       ← 原 HomeView.vue
      components/     ← Home 视图专属组件
        ActiveTaskBar/index.vue
        ChatPanel/index.vue
        EquipmentPanel/index.vue
        MarketPanel/index.vue
        QueuePopover/index.vue
        SettingsPanel/index.vue
        SidebarNav/index.vue
        SubjectPanel/index.vue

每个组件从单文件变成目录套 index.vue,好处有三:

  • defineOptions({ name }) 可以写了——单文件组件默认用文件名作 name,改成目录后需要手动声明,换来的是 Vue DevTools 里的组件名更清晰
  • 未来每个组件可以有自己的 composables.ts / types.ts——不需要提前创建,但目录结构已经为此留了空间
  • import 路径更自解释——@/views/Home/components/EquipmentPanel/index.vue../components/EquipmentPanel.vue 多了"属于 Home 视图"的语义

同时引入了 Vite 的 @ 路径别名,所有相对路径的跨目录 import 统一改成 @/stores/subjects 这种绝对路径写法。相对路径在扁平目录里还好,层级深了以后 ../../stores/xxx 的心智负担明显偏高——数几个点比直接写 @/stores 慢且容易出错。

MarketPanel 和 SettingsPanel 也从 HomeView 里的 render 函数占位组件,拆成了独立的 .vue 文件。render 函数写简单占位没问题,但一旦需要加模板、加交互、加样式,render 函数的可维护性远不如 SFC。早拆早省心。

踩到的坑

CSS 嵌套的 &--modifier 语法。气泡框按钮的样式用了 SCSS 风格的 &--primary / &--danger,但 Vue scoped style 不经过 SCSS 编译(项目没有给 scoped style 配 SCSS preprocessor),原生 CSS 嵌套不允许 & 直接接 -- 开头的类型选择器。Vite 构建时报了两条 css-syntax-error 警告。

修法是把嵌套展开成平铺的 .item-pop__btn--primary / .item-pop__btn--danger,传统但不会出问题。

el-popover 在 v-for 里的 key 稳定性。装备槽的 popover 用 v-for="key in leftSlots" 渲染,:key="key" 直接用槽位 key(head / chest 等),非常稳定。如果用数组 index 作 key,在动态增减元素时可能出现气泡框错位——popover 的显隐状态和 key 绑定,key 变了 popover 可能不关闭或指向错误元素。

收获

这次改造的核心体会:

  • 插槽化不是提前设计,是时机对了——当需求已经明确提出"市场菜单"时,从硬编码改成动态组件就是最小改动,不需要引入路由或状态机
  • 气泡框比 tooltip 的交互质量高一个台阶——点击触发、富文本排版、操作按钮,三个特性叠加让"看属性"和"做决策"变成了同一个流程而不是两个
  • 目录重组和功能改造可以同轮做——只要重组不改变运行时行为,风险就是零,而且新目录结构会让后续开发更顺手

下一篇会围绕市场面板展开——有了插槽化的基础,市场功能的实现只需要写组件、加 mock 数据、接 store,三步走完。