For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 为第三人称体素游戏构建一套可维护的菜单 UI 管理:进入游戏的 Main Menu、资源加载的 Loading、按 ESC 触发的 Pause/Resume 菜单、以及用于替代 tweakpane 的 Settings 页面,并与 Three.js 的输入/指针锁/暂停逻辑可靠联动。
Architecture: Vue 负责渲染 UI 与交互,Three.js 负责 3D 场景与逻辑;两者通过 Pinia(状态)+ mitt(即时事件) 解耦通信。UI 以一个轻量的 Screen 状态机 管理页面切换,Three.js 侧在 Experience 层做统一“暂停门禁”,保证 ESC 打开菜单时游戏逻辑不会继续跑。
Tech Stack: Vue 3 + Pinia、TailwindCSS、mitt、Three.js(现有 Experience 单例架构)、Playwright(UI 回归测试)
-
现状
Experience会在App.vuemount 后立刻初始化(new Experience(canvas)),并立即创建TimeRAF 循环,持续core:tick。Resources当前直接操作 DOM:#loading-screen/#loading-bar/#loading-percentage(不走 Vue)。- 输入系统由
InputManager全局监听键鼠事件,并通过 mitt 广播(但目前 未处理 ESC)。 - 指针锁由
PointerLockManager管理,点击 canvas 即锁定,ESC 解锁会发出pointer:unlocked。 - tweakpane 仅在
#debughash 下启用(Debug.active),但很多运行时参数(后处理、阴影质量等)确实需要可视化调参。
-
约束(最小改动优先)
- 不“硬耦合” Vue 去直接操作 Three.js 实例;统一通过 Pinia/mitt。
- 菜单 UI 不应破坏现有可运行场景;优先在 UI 层做“遮罩与输入禁用”。
- Settings 需要能控制:
Renderer.postProcessConfig、SHADOW_CONFIG.quality、PLAYER_CONFIG.mouseSensitivity、CAMERA_RIG_CONFIG、以及(可选)地形/渲染参数。
- 做法:页面加载后照常
new Experience();Loading/MainMenu 只是覆盖在画面上;在Experience.update()或关键控制器中通过uiStore.isPlaying/uiStore.isPaused做门禁,暂停时不更新 World/Player/Camera。 - 优点:改动最小、风险最低;与现有资源加载/World 初始化流程兼容。
- 缺点:Main Menu 阶段依然会生成世界(CPU/GPU 占用更高)。
- 做法:把
World中emitter.on('core:ready', ...)里的初始化逻辑改为监听game:start;Main Menu 阶段只显示静态背景或空场景。 - 优点:Main Menu 更轻;“开始游戏”语义更一致。
- 缺点:需要重排初始化顺序,涉及更多文件,回归风险上升。
- 优点:最省资源。
- 缺点:改动最大;会牵动大量依赖
new Experience()的类;不推荐。
screen:'loading' | 'mainMenu' | 'playing' | 'pauseMenu' | 'settings'mainMenuView:'root' | 'worldSetup'(screen === 'mainMenu'时的子页面)returnTo:null | 'mainMenu' | 'pauseMenu'(用于 Settings 返回)isPaused:boolean(在pauseMenu/settings时为 true)loading:{ progress: number; message: string }settings: UI 可调参数的快照(见 4.2)seedDraft:string(Main Menu 输入框的当前值;只允许纯数字;允许为空)
pendingNewWorld:boolean(是否正处于“覆盖式新建世界”流程;用于决定 Create 时是否弹覆盖确认)world:{ hasWorld: boolean; seed: string | null }hasWorld:是否已经创建过世界(决定 Main Menu 显示 Continue / New World)seed:当前世界使用的 seed(字符串存储,显示与调试用;应用时会解析为整数)
说明:这里的节点是 UI screen(不是 Three.js 的 scene)。你关心的“保留世界/覆盖世界”通过事件
game:create_world/game:reset_world实现。
stateDiagram-v2
[*] --> loading
loading --> mainMenu: core ready
state mainMenu {
[*] --> root
root --> worldSetup: Create World\n(首次) / New World\n(覆盖流程)
worldSetup --> root: Back
}
root --> playing: Continue\n(已有世界)
worldSetup --> playing: Create(seed)\n(首次 or 覆盖确认后)
playing --> pauseMenu: ESC
pauseMenu --> playing: Resume / ESC
pauseMenu --> mainMenu: MainMenu\n(保留世界, 保持暂停)
mainMenu --> settings: Settings
pauseMenu --> settings: Settings
settings --> mainMenu: Back (returnTo)
settings --> pauseMenu: Back (returnTo)
- 启动:
loading(直到资源 ready) - 资源 ready:
mainMenu - Main Menu(Root)
hasWorld === false:显示Create World / Settings / How to Play(可选Credits)hasWorld === true:显示Continue / New World / Settings(可选How to Play / Credits)
- Create World(首次创建)
- 在 Root 点
Create World:进入mainMenuView='worldSetup'(seed 输入页),此时pendingNewWorld=false - 在
worldSetup点Create:读取seedDraft(纯数字规则见下),触发game:create_world({ seed }),并进入playing
- 在 Root 点
- Continue(已有世界)
- 在 Root 点
Continue:不改 seed,不重建世界,进入playing
- 在 Root 点
- New World(覆盖当前世界)
- 在 Root 点
New World:进入mainMenuView='worldSetup'(seed 输入页),此时pendingNewWorld=true - 在
worldSetup点Create:先弹出覆盖确认弹窗;确认后触发game:reset_world({ seed })覆盖重建,并进入playing
- 在 Root 点
- Seed 纯数字校验(Create/Reset 共用)
seedDraft.trim()为空 → 生成随机纯数字 seed(建议范围0 ~ 2_000_000_000)- 非空则必须匹配
/^\d+$/,否则提示错误并阻止 Create - 解析为整数:
Number.isSafeInteger(seed)且seed >= 0
- ESC(playing 时):
pauseMenu(并触发isPaused=true+pointerLock.exitLock()) - Resume:回
playing(并提示用户点击 canvas 重新锁定,或在按钮点击时主动requestLock()) - Main Menu(从 PauseMenu):回到
mainMenu- 保留当前世界:不销毁、不重建;继续保持暂停(
isPaused=true)
- 保留当前世界:不销毁、不重建;继续保持暂停(
- Settings(从 main/pause 进入):
settings,returnTo记录来源 - Settings 返回:回到
returnTo
screen === 'settings':回到returnToscreen === 'pauseMenu':回到playingscreen === 'playing':进入pauseMenuscreen === 'mainMenu':无动作(或弹“确定退出?”——暂不做)screen === 'loading':无动作
- Create:
src/components/menu/UiRoot.vue- 负责根据
uiStore.screen渲染对应页面(Main/Loading/Pause/Settings) - 统一 z-index、背景遮罩、键盘提示等
- 负责根据
- Create:
src/components/menu/LoadingScreen.vue - Create:
src/components/menu/MainMenu.vue- Main Menu 两层结构(推荐,贴近 MC 原版流程)
- Root(导航菜单):只放主按钮,不显示 seed 输入框
- World Setup(创建世界):显示 seed 输入框 + Create/Random/Back;覆盖式 New World 在这里触发确认弹窗
- Logo(你新增的 Minecraft Logo)
- 放在 Root/WorldSetup 顶部,按钮区域仍用
.mc-menu居中 - 样式建议:
max-width: min(720px, 92vw); height:auto; image-rendering: pixelated; - 无需改
.mc-menu的 grid,只需在外层做纵向 flex 布局与间距(例如gap: 24px)
- 放在 Root/WorldSetup 顶部,按钮区域仍用
- Main Menu 两层结构(推荐,贴近 MC 原版流程)
- Create:
src/components/menu/PauseMenu.vue- Pause Menu 按钮:Resume / Main Menu(保留世界)/ Settings /(可选 HowToPlay)
- Create:
src/components/menu/SettingsMenu.vue- 分组:Graphics / Controls / Gameplay(先做前两组也可以)
- Create:
src/vue/uiStore.jsdefineStore('ui', ...)管理状态机- action:
toMainMenu/toPlaying/toPause/toSettings/applySettings/updateLoadingProgress - action(新增):
createWorld({ seedDraft }) / continueGame() / resetWorld({ seedDraft }) / setSeedDraft(seed) - 持久化:
localStorage(只保存 settings,不保存 screen)
- Modify:
src/App.vue- 在现有
Crosshair/MiniMap/ExamplePanel上方挂UiRoot UiRoot的 z-index > 现有 HUD- 最小侵入:不改 three canvas 初始化方式(走方案 A)
- 在现有
原则:暂停 = “不推进游戏逻辑”。UI 自己运行不依赖 3D tick。
建议逻辑(实现时可二选一):
- 轻暂停:
Experience.update()中如果uiStore.isPaused为 true,则跳过world.update()/camera.update(),保留renderer.update()(画面仍渲染,便于保留后处理/视觉) - 硬暂停(更省资源):
Time支持pause()停止 RAF;Resume 时resume()继续。UI 仍然由浏览器渲染。
目标是覆盖现有 tweakpane 的“运行时可调”需求(不追求 100% 参数覆盖,先做最有价值的)。
- Graphics
- Bloom:
enabled/strength/radius/threshold(对应Renderer.postProcessConfig.bloom) - SpeedLines:
enabled/color/density/speed/thickness/minRadius/maxRadius/randomness - Shadow quality:
low/medium/high(对应SHADOW_CONFIG.quality,通过现有shadow:quality-changed事件同步) - Pixel ratio cap(可选):
Sizes.pixelRatio上限(需要改Sizes,可后置)
- Bloom:
- Controls
PLAYER_CONFIG.mouseSensitivityCAMERA_RIG_CONFIG.follow.mouseTargetY.*(sensitivity、invertY、maxOffset、returnSpeed 等)
- Gameplay(可选后置)
TERRAIN_PARAMS.scale/magnitude/offset/fbm...(注意:这类变更往往需要“全量重新生成地形”,必须在 UI 上给出明确提示与二次确认)
- Pinia 存状态,mitt 发“应用事件”,Three.js 侧订阅后把参数写回到运行时对象。
ui:settings-changed(payload 为 settings 子树 + changedKeys)ui:pause-changed(true/false)
- 世界生命周期事件(新增)
game:create_world(payload:{ seed: string })——首次创建世界(或视为 reset 的别名)game:reset_world(payload:{ seed: string })——覆盖式重建世界(你已选择 A:覆盖当前世界)game:request_pointer_lock(可选)——由 UI 在按钮点击时触发,Three.js 侧调用pointerLock.requestLock()
- Renderer 侧:监听
ui:settings-changed,把 bloom/speedLines 直接赋值到 pass/uniform - Player/CameraRig 侧:监听
ui:settings-changed,更新灵敏度与相机手感参数 - Terrain/Chunk 侧(如做 Gameplay):需要单独的
ui:regenerate-terrain事件,避免每个滑块变化都触发重建
从第一性原理:seed 只在“生成”时生效,所以我们把 seed 的应用点锁死在 create/reset world 事件上,避免 Continue/Settings 误改世界。
- Seed 输入规则
- 只允许纯数字(UI 层校验并阻止非法输入;Three.js 侧也做二次校验,避免脏数据)
- 空字符串:视为随机 seed(纯数字,建议生成范围:
0 ~ 2_000_000_000) - 解析:
const seed = Number.parseInt(seedString, 10),并检查Number.isSafeInteger(seed)
- World 重建接口(建议)
- 在
World或ChunkManager暴露reset({ seed }):- destroy 当前 chunk/player/交互模块
- 使用新 seed 重新初始化
ChunkManager(覆盖CHUNK_BASIC_CONFIG.seed或在构造参数里传入) - 重新创建 Player/CameraRig/Environment
- 在
- 与“返回主菜单保留世界”不冲突
- 返回主菜单只切 UI,不调用 destroy/reset;世界保持暂停状态。
目标:让 “New World(覆盖当前世界)” 的误操作成本接近 0,并且视觉贴近 MC 原版确认框。
- 触发时机(强约束)
- 仅在
world.hasWorld === true且pendingNewWorld === true时,用户在mainMenuView='worldSetup'点击Create才弹窗。 - Root 点击
New World不直接弹窗(允许用户先编辑 seed)。
- 仅在
- 文案(建议英文,更贴近原版)
- Title:
Create New World? - Body:
This will overwrite your current world.+Seed: 123456 - Buttons:
Cancel/Confirm
- Title:
- 交互规则
- 默认聚焦:
Cancel(防误触) ESC:等价 Cancel- 点击遮罩:等价 Cancel
Enter:仅当 Confirm 聚焦时才执行 Confirm(否则仍走 Cancel 或不处理)
- 默认聚焦:
- 视觉规则
- 额外遮罩:
rgba(0,0,0,0.65)(阻止点击穿透) - 面板:2px 黑边 + inset 高光/阴影,背景深灰(贴近 MC 对话框)
- 按钮:复用
.mc-button,底部并排两个(可复用.double布局)
- 额外遮罩:
- 保持
Resources现有 DOM 操作不动(确保不破坏当前 loading)。 - Vue 的
LoadingScreen先用 “资源加载完成前的占位 UI”,并在core:ready后切到mainMenu。
- Modify:
src/js/utils/resources.js- 不再直接 query
#loading-*,改为emitter.emit('core:loading-progress', { progress, percentage })
- 不再直接 query
- Vue 侧订阅该事件,把进度展示在
LoadingScreen - index.html 里原 loading DOM 可以删除(属于破坏性改动,实施前再确认)
Files:
- Create:
src/vue/uiStore.js
Steps:
uiStore提供screen/isPaused/loadingProgress/settings/world/seedDraft- 增加
seedDraft的纯数字校验与错误提示状态(例如seedError) applySettings()写入 localStorage(仅保存 settings,不保存 world)- 手动验证:首次进入能切换到 mainMenu、seed 输入非法会提示并阻止 Create
Files:
- Create:
src/components/menu/UiRoot.vue - Create:
src/components/menu/LoadingScreen.vue - Create:
src/components/menu/MainMenu.vue - Create:
src/components/menu/PauseMenu.vue - Create:
src/components/menu/SettingsMenu.vue - Modify:
src/App.vue - Modify:
src/scss/global.scss(把 Minecraft btn style 做成全局 class) - Create:
public/textures/ui/bgbtn.png(建议把按钮背景图本地化,避免外链不稳定)
Steps:
- 先把 4 个页面“能显示/能切换”做通(按钮改 screen)
- UI 暂不联动 Three.js 参数(先跑通交互链路)
- MainMenu 内实现 seed 输入框 + Random + Create/Continue/NewWorld(含确认弹窗)
- 使用
.mc-menu/.mc-button作为全局样式类(参考你提供的 codepen 版本)
Files:
- Modify:
src/js/utils/input.js - Modify:
src/vue/uiStore.js
Steps:
InputManager处理 Escape:emitter.emit('ui:escape')- Vue 层订阅
ui:escape并按 2.3 优先级切 screen
Files:
- Modify:
src/js/utils/pointer-lock.js(如需对外暴露 request/exit,或新增 mitt 事件监听) - Modify:
src/vue/uiStore.js/UiRoot.vue
Steps:
- Pause:发
ui:pause-changed(true),Three.js 侧调用pointerLock.exitLock() - Resume:提示“点击画面继续”,或按钮点击时调用
requestPointerLock()(必须是用户手势) - Main Menu(从 PauseMenu)只切 UI,不销毁世界;并保持
isPaused=true
Files:
- Modify:
src/js/world/world.js(增加reset({ seed })或等价入口) - Modify:
src/js/world/terrain/chunk-manager.js(确保构造时可注入 seed,或提供 setSeed+rebuild) - Modify:
src/js/experience.js(监听game:create_world/game:reset_world,并调用this.world.reset(...)) - Modify:
src/vue/uiStore.js - Modify:
src/components/menu/MainMenu.vue
Steps:
- UI:Create/NewWorld 按钮点击时 emit
game:create_world/game:reset_world(携带 seed) - Three.js:收到事件后执行覆盖式重建(destroy旧世界 -> 用新 seed 重建)
- UI:重建完成后进入
playing并请求 pointer lock(仍需用户手势)
Files:
- Modify:
src/js/renderer.js - Modify:
src/js/config/shadow-config.js(如需要额外 getter/setter) - Modify:
src/js/world/player/player-movement-controller.js(或实际使用 mouseSensitivity 的地方) - Modify:
src/js/camera/camera-rig.js(或读取CAMERA_RIG_CONFIG的地方) - Modify:
src/components/menu/SettingsMenu.vue
Steps:
- SettingsMenu 改动 store → store emit
ui:settings-changed - Three.js 侧监听并应用(只做少量关键参数:Bloom enabled/strength + shadow quality + mouseSensitivity)
Files:
- Modify:
src/js/utils/resources.js - Modify:
src/components/menu/LoadingScreen.vue - Modify:
index.html(最终移除旧 loading DOM)
- Loading → Main Menu
- 首次进入显示 Loading,资源 ready 后进入 Main Menu
- Create World(seed 纯数字)
- seed 为空:可开始,且会生成一个纯数字 seed
- seed 为非数字:按钮禁用或点击后提示错误,不允许开始
- seed 为纯数字:可开始并进入 playing
- ESC 与 Pause/Main Menu
- playing 按 ESC → pauseMenu
- pauseMenu 点击 Main Menu → 回 mainMenu,且出现 Continue(世界保留且保持暂停)
- mainMenu 点击 Continue → 回 playing(不触发 reset)
- New World(覆盖确认)
- mainMenu 点击 New World → 必须二次确认
- 确认后会 reset world,并进入 playing
- 浏览器 PointerLock 必须由用户手势触发
- 规避:Resume 按钮/Start 按钮点击时触发
requestPointerLock();非手势场景只提示点击 canvas
- 规避:Resume 按钮/Start 按钮点击时触发
- Terrain 参数变更可能需要全量重建
- 规避:Gameplay 组参数后置;先只做 Graphics/Controls
- 暂停门禁放错位置会导致“看似暂停但角色仍移动”
- 规避:统一在
Experience.update()入口做门禁,或者在 Player/World update 入口处二次防护
- 规避:统一在
- Loading:进入页面立即看到 Loading;资源 ready 后自动进入 Main Menu(或 Playing,按最终设定)
- Main Menu:
- 首次进入:Create World + seed 输入(可空)+ Random + Settings
- 已有世界:Continue + New World(覆盖确认)+ seed 输入 + Random + Settings
- Resume (ESC):Playing 按 ESC 打开 PauseMenu;Resume 能回到 Playing;Pause 时不再推进游戏逻辑
- Settings:不依赖
#debug;能实时调节至少 3 项运行时参数(建议:Bloom 强度、阴影质量、鼠标灵敏度),并能持久化
/* Minecraft Style Button */
.mc-button {
height: var(--btn-size);
width: calc(var(--btn-size) * 10);
cursor: pointer;
overflow: hidden;
white-space: nowrap;
user-select: none;
background: #999 url('https://i.ibb.co/rb2TWXL/bgbtn.png') center / cover;
image-rendering: pixelated;
border: 2px solid #000;
/* Mouse over */
&:hover .title {
background-color: rgba(100, 100, 255, .45);
text-shadow: 2px 2px #202013CC;
color: #FFFFA0;
}
&:active .title {
box-shadow: inset -2px -4px #0004, inset 2px 2px #FFF5;
}
/* Button title */
.title {
width: 100%; height: 100%;
padding-bottom: .3em;
@include flex-center-hv;
color: #DDD;
text-shadow: 2px 2px #000A;
box-shadow: inset -2px -4px #0006, inset 2px 2px #FFF7;
}
/* Others */
&.full { width: 100%; height: 100%; }
&.lang {
& img { width: 26px; height: 26px;}
& .title { padding-bottom: 0; } }
}Files:
- Modify:
src/scss/global.scss - btn_bg.png 文件已经加入到 hub 文件夹下
CSS/SCSS 迁移注意点:
- 你提供的片段是 SCSS(嵌套
&)+ mixinflex-center-hv。落到本项目时:- 用纯 CSS:把 mixin 改成
.title { display:flex; align-items:center; justify-content:center; } - 保持
.mc-menu、.mc-button、.mc-button .title都是 全局 class(不要 scoped)
- 用纯 CSS:把 mixin 改成
- 背景图建议本地化:
background: #999 url('/textures/ui/bgbtn.png') center / cover;(避免外链不可控)