Skip to content

玄学放置 · 留住玩家的四个细节

起因

项目第一版接口对接(第 13 篇)引入了 availableTasks getter——按学科等级过滤,只有等级满足要求的任务才会展示在面板里。这个设计逻辑上没问题,但体验上有盲区:玩家看不到自己还没解锁的任务,也就不知道继续升级有什么可期待。

放置游戏的核心驱动力之一就是"下一个目标"。如果低等级玩家只能看到两三个入门任务,他不知道后面的任务更长、经验更多、掉落更稀有,升级的欲望就打折了。手游里常见的做法是:灰掉高等级内容,让你看到它存在,但不让你碰——这比彻底隐藏有效得多。

除了任务锁定逻辑的调整,这次还顺手修了三个体验问题:公屏聊天进入时缺少欢迎消息(之前 WebSocket 改造时把 mock 的欢迎语一并删了)、聊天面板里自己的消息和别人的消息看起来一模一样、设置页面还是个占位符。四个改动都不大,但都是"看到就能感知到"的地方。

关键决策

锁定任务的展示策略。 有三种做法可选:

  1. 灰掉 + 不可点击——能看见但完全没有交互
  2. 灰掉 + 可点击弹出气泡框,框内只显示"需要 Lv.X"——能看见,交互后知道缺几级
  3. 灰掉 + 可点击弹出气泡框,框内显示完整信息 + 底部提示等级不足——能看见,能看到完整掉落和收益,底部告知需要几级

选了第三种。理由是放置游戏的玩家对"收益预期"非常敏感——如果我能看到"命名仪式"单次 42 经验、掉落稀有素材,我就有动力去升级语言学科。如果只看到"需要 Lv.50",没有具体的收益信息,驱动力弱很多。

聊天的用户区分。 消息类型原本就在 ChatMessage 里定义了 system | user | self 三种,CSS 也有对应的颜色样式(系统金色、他人紫色、自己青色)。问题出在数据源头——服务端广播消息时,所有玩家发出来的消息 type 都是 user,不会帮你标记"这条是你自己发的"。解决方案在客户端:用 displayMessages 计算属性,把 msg.user === currentUser 的消息强制覆盖为 self 类型。

实现要点

availableTasks 变 allTasks

subjects store 的 getter 改了一行:从 filter 变成全量返回。

ts
// 旧:按等级过滤
availableTasks(): Task[] {
  return this.current.tasks.filter(t => this.current.level >= t.requiredLevel)
}

// 新:全部返回,由视图层决定展示方式
allTasks(): Task[] {
  return this.current
  return this.current.tasks
}

视图层新增两个辅助函数:isLocked(task) 判断等级是否不足,lockedClass(task) 返回锁定态的 CSS 类名。模板里 v-forstore.availableTasks 改为 store.allTasks,任务卡片根据锁定状态切换样式和气泡框内容。

锁定态的视觉设计

任务卡片锁定时做了三处区分:

  • 虚线边框 + 半透明border-style: dashed; opacity: 0.55,一眼看出和可执行任务不同
  • 徽章替换:运行中显示"进行中"(青色),排队中显示"排队中"(金色),锁定时显示锁图标 + "Lv.X"
  • hover 禁用:锁定卡片 cursor: not-allowed,hover 时不上浮、不发光

气泡框内的区分更关键:非锁定任务显示次数选择和操作按钮,锁定任务用一行提示替代——"需要学科等级 Lv.X,当前 Lv.Y"。但任务的耗时、经验、掉落表照常展示,让玩家能看到完整的收益预期。

聊天欢迎消息

chat store 的 connect 方法里,连接前先检查 messages.length === 0,是空列表就插入三条系统消息:

ts
function buildWelcomeMessages(): ChatMessage[] {
  const now = Date.now()
  return [
    { id: now - 3, user: '系统', content: '欢迎来到学科放置 — Metaphysics Idle!', time: now - 3000, type: 'system' },
    { id: now - 2, user: '系统', content: '点击左侧学科图标切换任务面板,升级解锁更多任务', time: now - 2000, type: 'system' },
    { id: now - 1, user: '系统', content: '公屏聊天已连接,祝学业有成!', time: now - 1000, type: 'system' }
  ]
}

时间戳用 now - N 倒排,保证排序正确。收到服务端 history 消息时,先保留本地的系统欢迎消息,再拼接服务端历史——避免欢迎语被服务端空历史覆盖。

消息类型客户端校正

ChatPanel 组件新增 displayMessages 计算属性:

ts
const displayMessages = computed(() =>
  props.messages.map(msg => {
    if (msg.type === 'system') return { ...msg, displayType: 'system' }
    if (msg.user === props.currentUser) return { ...msg, displayType: 'self' }
    return { ...msg, displayType: msg.type || 'user' }
  })
)

服务端数据里的 type 字段保持原样不动,客户端只根据 user === currentUser 重新判定展示类型。这样做的好处是逻辑单一——不需要改服务端,也不需要改消息协议,纯视图层的校正。

设置面板:左右布局 + 个人页

旧的设置面板就是一个居中的占位符。改成左右分栏:左侧 140px 宽的菜单栏,右侧弹性内容区。目前只有一个"个人"菜单项,展示玩家信息卡片(头像 + 用户名 + 身份标签)、数据概览(金币、背包物品数、队列项数)、学科等级一览(9 格网格,点击可跳转对应学科面板)。

学科概览的跳转用了 store.selectSubject(s.id)——和侧栏导航共用同一个 action,切换后 Home 的动态组件会自动渲染 SubjectPanel。这比 router.push 轻量,不需要路由层面的改动。

注意事项

像素图标的 addPending 维护。 这次新增了 lock(挂锁)和 avatar(人像)两个图标。pixelIcons.ts 是一个导出常量 ICONS 的对象字面量,新图标必须加在正确位置、逗号不能漏。之前踩过一次逗号遗漏导致构建失败的坑(第 13 篇有记录),这次格外注意了。

欢迎消息的 id 冲突。 欢迎消息的 id 用 Date.now() - N 生成,服务端历史消息的 id 是数据库自增整数。两者范围不同(毫秒时间戳 vs 小整数),碰撞概率极低,但严格来说不应该假设不会碰撞。如果后续要严谨处理,可以在 id 前加前缀区分来源。

statusOf 和 isLocked 的关系。 锁定任务的 statusOf 强制返回 'idle',即使该任务理论上可以在队列中(虽然实际上不可能,因为之前根本无法入队)。如果将来有"管理员解锁所有任务"之类的调试功能,需要同步调整 statusOf 的判断逻辑。

收获

这次改动的核心思路是"可见但不可达"——让玩家看到完整的游戏内容,用等级门槛作为访问控制,而不是用可见性。这和手游里"关卡列表全部展示、未解锁的灰掉并标注推荐等级"是同一个设计模式。

四个改动里,设置面板是唯一"从零搭"的组件。左右分栏布局本身没什么技术难点,值得记的是个人页里的"学科概览"——一个小网格,点击直接跳转学科面板。这种"设置页面作为全局导航的补充入口"的思路,比单纯堆数据展示更有用,后续如果有"成就""统计"之类的面板,也可以走同样的路由跳转模式。

下一篇大概率要处理商店系统——市场面板和设置面板一样,目前也是占位符。商品定价、购买流程、库存管理,这些是放置游戏经济系统的核心,需要认真设计。