Skip to content

Latest commit

 

History

History
427 lines (341 loc) · 21.1 KB

File metadata and controls

427 lines (341 loc) · 21.1 KB

Menu UI(Main/Loading/Pause/Settings)Implementation Plan

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 回归测试)


0. 现状与约束(从第一性原理出发)

  • 现状

    • Experience 会在 App.vue mount 后立刻初始化(new Experience(canvas)),并立即创建 Time RAF 循环,持续 core:tick
    • Resources 当前直接操作 DOM:#loading-screen/#loading-bar/#loading-percentage(不走 Vue)。
    • 输入系统由 InputManager 全局监听键鼠事件,并通过 mitt 广播(但目前 未处理 ESC)。
    • 指针锁由 PointerLockManager 管理,点击 canvas 即锁定,ESC 解锁会发出 pointer:unlocked
    • tweakpane 仅在 #debug hash 下启用(Debug.active),但很多运行时参数(后处理、阴影质量等)确实需要可视化调参。
  • 约束(最小改动优先)

    • 不“硬耦合” Vue 去直接操作 Three.js 实例;统一通过 Pinia/mitt。
    • 菜单 UI 不应破坏现有可运行场景;优先在 UI 层做“遮罩与输入禁用”。
    • Settings 需要能控制:Renderer.postProcessConfigSHADOW_CONFIG.qualityPLAYER_CONFIG.mouseSensitivityCAMERA_RIG_CONFIG、以及(可选)地形/渲染参数。

1. 方案对比(推荐 A)

方案 A(推荐):场景照常初始化 + UI 层控制“是否可玩”

  • 做法:页面加载后照常 new Experience();Loading/MainMenu 只是覆盖在画面上;在 Experience.update() 或关键控制器中通过 uiStore.isPlaying / uiStore.isPaused 做门禁,暂停时不更新 World/Player/Camera。
  • 优点:改动最小、风险最低;与现有资源加载/World 初始化流程兼容。
  • 缺点:Main Menu 阶段依然会生成世界(CPU/GPU 占用更高)。

方案 B:资源加载后不初始化 World,Start 才初始化

  • 做法:把 Worldemitter.on('core:ready', ...) 里的初始化逻辑改为监听 game:start;Main Menu 阶段只显示静态背景或空场景。
  • 优点:Main Menu 更轻;“开始游戏”语义更一致。
  • 缺点:需要重排初始化顺序,涉及更多文件,回归风险上升。

方案 C:Main Menu 不创建 Experience,Start 后才创建

  • 优点:最省资源。
  • 缺点:改动最大;会牵动大量依赖 new Experience() 的类;不推荐。

2. UI 状态机(Screen State Machine)

2.1 状态定义(Pinia)

  • 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(字符串存储,显示与调试用;应用时会解析为整数)

2.2 基本流转(Mermaid)

说明:这里的节点是 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

2.2 基本流转

  • 启动loading(直到资源 ready)
  • 资源 readymainMenu
  • 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
    • worldSetupCreate:读取 seedDraft(纯数字规则见下),触发 game:create_world({ seed }),并进入 playing
  • Continue(已有世界)
    • 在 Root 点 Continue:不改 seed,不重建世界,进入 playing
  • New World(覆盖当前世界)
    • 在 Root 点 New World:进入 mainMenuView='worldSetup'(seed 输入页),此时 pendingNewWorld=true
    • worldSetupCreate:先弹出覆盖确认弹窗;确认后触发 game:reset_world({ seed }) 覆盖重建,并进入 playing
  • 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 进入)settingsreturnTo 记录来源
  • Settings 返回:回到 returnTo

2.3 ESC 行为优先级(避免“多层弹窗”混乱)

  1. screen === 'settings':回到 returnTo
  2. screen === 'pauseMenu':回到 playing
  3. screen === 'playing':进入 pauseMenu
  4. screen === 'mainMenu':无动作(或弹“确定退出?”——暂不做)
  5. screen === 'loading':无动作

3. 文件结构(建议)

3.1 新增 UI 组件(Vue)

  • 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
  • Create: src/components/menu/PauseMenu.vue
    • Pause Menu 按钮:Resume / Main Menu(保留世界)/ Settings /(可选 HowToPlay)
  • Create: src/components/menu/SettingsMenu.vue
    • 分组:Graphics / Controls / Gameplay(先做前两组也可以)

3.2 新增 Store

  • Create: src/vue/uiStore.js
    • defineStore('ui', ...) 管理状态机
    • action:toMainMenu/toPlaying/toPause/toSettings/applySettings/updateLoadingProgress
    • action(新增):createWorld({ seedDraft }) / continueGame() / resetWorld({ seedDraft }) / setSeedDraft(seed)
    • 持久化:localStorage(只保存 settings,不保存 screen)

3.3 修改入口挂载

  • Modify: src/App.vue
    • 在现有 Crosshair/MiniMap/ExamplePanel 上方挂 UiRoot
    • UiRoot 的 z-index > 现有 HUD
    • 最小侵入:不改 three canvas 初始化方式(走方案 A)

4. Three.js 侧联动设计

4.1 暂停门禁(推荐落点:Experience.update()

原则:暂停 = “不推进游戏逻辑”。UI 自己运行不依赖 3D tick。

建议逻辑(实现时可二选一):

  • 轻暂停Experience.update() 中如果 uiStore.isPaused 为 true,则跳过 world.update() / camera.update(),保留 renderer.update()(画面仍渲染,便于保留后处理/视觉)
  • 硬暂停(更省资源)Time 支持 pause() 停止 RAF;Resume 时 resume() 继续。UI 仍然由浏览器渲染。

4.2 Settings 数据模型(Pinia)

目标是覆盖现有 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,可后置)
  • Controls
    • PLAYER_CONFIG.mouseSensitivity
    • CAMERA_RIG_CONFIG.follow.mouseTargetY.*(sensitivity、invertY、maxOffset、returnSpeed 等)
  • Gameplay(可选后置)
    • TERRAIN_PARAMS.scale/magnitude/offset/fbm...(注意:这类变更往往需要“全量重新生成地形”,必须在 UI 上给出明确提示与二次确认)

4.3 参数应用策略(Pinia + mitt)

  • 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 事件,避免每个滑块变化都触发重建

4.4 Seed 与世界重建(覆盖式 New World)

从第一性原理: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 重建接口(建议)
    • WorldChunkManager 暴露 reset({ seed })
      • destroy 当前 chunk/player/交互模块
      • 使用新 seed 重新初始化 ChunkManager(覆盖 CHUNK_BASIC_CONFIG.seed 或在构造参数里传入)
      • 重新创建 Player/CameraRig/Environment
  • 与“返回主菜单保留世界”不冲突
    • 返回主菜单只切 UI,不调用 destroy/reset;世界保持暂停状态。

4.5 覆盖确认弹窗(MC 风格 Dialog)

目标:让 “New World(覆盖当前世界)” 的误操作成本接近 0,并且视觉贴近 MC 原版确认框。

  • 触发时机(强约束)
    • 仅在 world.hasWorld === truependingNewWorld === true 时,用户在 mainMenuView='worldSetup' 点击 Create 才弹窗。
    • Root 点击 New World 不直接弹窗(允许用户先编辑 seed)。
  • 文案(建议英文,更贴近原版)
    • Title: Create New World?
    • Body: This will overwrite your current world. + Seed: 123456
    • Buttons: Cancel / Confirm
  • 交互规则
    • 默认聚焦:Cancel(防误触)
    • ESC:等价 Cancel
    • 点击遮罩:等价 Cancel
    • Enter:仅当 Confirm 聚焦时才执行 Confirm(否则仍走 Cancel 或不处理)
  • 视觉规则
    • 额外遮罩:rgba(0,0,0,0.65)(阻止点击穿透)
    • 面板:2px 黑边 + inset 高光/阴影,背景深灰(贴近 MC 对话框)
    • 按钮:复用 .mc-button,底部并排两个(可复用 .double 布局)

5. Loading 页面改造策略(兼容现状,分两步走)

Step A:最小成本接入(推荐先做)

  • 保持 Resources 现有 DOM 操作不动(确保不破坏当前 loading)。
  • Vue 的 LoadingScreen 先用 “资源加载完成前的占位 UI”,并在 core:ready 后切到 mainMenu

Step B:彻底 Vue 化(后续迭代)

  • Modify: src/js/utils/resources.js
    • 不再直接 query #loading-*,改为 emitter.emit('core:loading-progress', { progress, percentage })
  • Vue 侧订阅该事件,把进度展示在 LoadingScreen
  • index.html 里原 loading DOM 可以删除(属于破坏性改动,实施前再确认)


7. 任务拆解(可执行 Implementation Tasks)

Task 1: 新建 UI Store(Screen 状态机)

Files:

  • Create: src/vue/uiStore.js

Steps:

  • uiStore 提供 screen/isPaused/loadingProgress/settings/world/seedDraft
  • 增加 seedDraft 的纯数字校验与错误提示状态(例如 seedError
  • applySettings() 写入 localStorage(仅保存 settings,不保存 world)
  • 手动验证:首次进入能切换到 mainMenu、seed 输入非法会提示并阻止 Create

Task 2: 增加 UiRoot + Loading/MainMenu/Pause/Settings 组件骨架

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 版本)

Task 3: ESC 打开/关闭 PauseMenu(Input → UI)

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

Task 4: Pause 时退出指针锁 + Resume 行为

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

Task 4.5: 覆盖式新建世界(Seed → World Reset)

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(仍需用户手势)

Task 5: Settings → Renderer/Shadow/Controls 的最小闭环

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)

Task 6: Loading 进度 Vue 化(可选迭代)

Files:

  • Modify: src/js/utils/resources.js
  • Modify: src/components/menu/LoadingScreen.vue
  • Modify: index.html(最终移除旧 loading DOM)

8.5 手动测试清单(由你手动验收用)

  • 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

8. 风险清单与规避

  • 浏览器 PointerLock 必须由用户手势触发
    • 规避:Resume 按钮/Start 按钮点击时触发 requestPointerLock();非手势场景只提示点击 canvas
  • Terrain 参数变更可能需要全量重建
    • 规避:Gameplay 组参数后置;先只做 Graphics/Controls
  • 暂停门禁放错位置会导致“看似暂停但角色仍移动”
    • 规避:统一在 Experience.update() 入口做门禁,或者在 Player/World update 入口处二次防护

9. 验收标准(你关心的 4 个页面)

  • 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 按钮样式(全局 class 落地建议)

/* 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(嵌套 &)+ mixin flex-center-hv。落到本项目时:
    • 用纯 CSS:把 mixin 改成 .title { display:flex; align-items:center; justify-content:center; }
    • 保持 .mc-menu.mc-button.mc-button .title 都是 全局 class(不要 scoped)
  • 背景图建议本地化:background: #999 url('/textures/ui/bgbtn.png') center / cover;(避免外链不可控)