何时阅读:改启动流程;改主循环节奏;改 AppState 状态切换;改输入/相机/UI 集成 关联文档:
README.md·architecture.md·features/physics.md·features/ui.md·networking/prediction.md·features/persistence.md
client 是 orchestrator:把 core / render / server / net 粘合成一个 WASM 应用。
- 浏览器入口(
#[wasm_bindgen(start)]) - AppState 状态机(Lobby / Connecting / InGame / EscMenu / ChatOpen)
- 主循环(RAF + 固定 60Hz 逻辑帧累加器)
- 输入(键盘/鼠标)、相机控制、本地物理预测、DDA 射线
- UI(egui)— 大厅、HUD、暂停、聊天、玩家列表、名牌
- OPFS 持久化的具体实现(
web_sys::FileSystemDirectoryHandle+ palette/RLE 压缩) - 网格化任务调度(mesh job queue)
client crate 的 cargo crate-type 设为 ["cdylib", "rlib"],便于 wasm-bindgen 输出。
| 阶段 | 包含 |
|---|---|
| Phase 2 ✅ | AppState::{Lobby, InGame};Game 子结构持 server / net / camera / mesh_jobs / chunk_loader;ui::lobby 实装;mesh_jobs.rs / chunk_loader.rs 新模块;Fly 模式相机 |
| Phase 3 ✅ | Walk 模式 + physics.rs + raycast.rs;挖放 hotbar;PendingActions rollback;server validate_break/place 闭环;选中方块线框 SelectionPass;PlayerInput 上报 |
| Phase 4 ✅ | AppState::Connecting / Disconnected;NetEndpoint::Host / Remote(Ping-Pong 闭环) |
| Phase 5 ✅ | prediction.rs(InputHistory + reconcile_self)/ interp.rs(PlayerInterp 100ms 延迟)/ chunk_assembler.rs(ChunkSnapshot 分片组装);远端玩家身体渲染(PlayerPass);outbox+Recipient 路由;OPFS 持久化整体延后至 Phase 8 |
| Phase 8 | 补 storage.rs OPFS 完整实现(WorldStorage trait + OpfsStorage + prime + 按需 load + 1s flush + pagehide) |
下面 §4 起的 App 完整结构是Phase 5+ 终态。Phase 2 实际仅需子集,标注见 §4。
crates/client/src/
├── lib.rs wasm 入口 + 主循环 + HUD 绘制 + 事件监听
├── app.rs AppState 状态机 + App / Game 主结构
├── camera.rs 第一人称相机
├── input.rs 键盘/鼠标输入管理
├── mesh_jobs.rs [Phase 2] 网格化任务队列 + 分帧调度
├── chunk_loader.rs [Phase 2] 区块滚动加载 / 卸载 + affected_chunks 工具
├── physics.rs [Phase 3 ✅] 玩家本地物理预测(Walk/Fly 双模式)
├── raycast.rs [Phase 3 ✅] DDA 射线
├── hotbar.rs [Phase 3 ✅] 9 格快捷栏 + 方块标签
├── prediction.rs [Phase 3 ✅] 挖放 PendingActions(rollback);[Phase 5] 完整输入历史+协调
├── interp.rs [Phase 5 ✅] 远端玩家位置插值(PlayerInterp, 100ms 延迟)
├── chunk_assembler.rs [Phase 5 ✅] ChunkSnapshot 分片接收与组装
├── storage.rs [Phase 8] OPFS 异步包装(`WorldStorage` trait + `OpfsStorage`;当前为 stub)
└── ui/
├── mod.rs UI 总入口(按 AppState 路由)
├── lobby.rs [Phase 2] 大厅 + Connecting UI(单机/Host/Remote 入口 + 连接进度)
├── hud.rs [Phase 1+] HUD 绘制函数(供 lib.rs 调用)
├── pause.rs [Phase 6] 暂停菜单(当前 stub)
├── chat.rs [Phase 6] 聊天框 + 消息历史(当前 stub)
├── players.rs [Phase 6] 玩家名牌(3D billboard)+ 玩家列表 widget(当前 stub)
└── ui_state.rs UI 状态哈希(防止重复渲染判断)
Phase 2 实装:直接通过浏览器原生 API(add_event_listener_with_callback)注册事件,不引入 winit 事件循环。#[wasm_bindgen(start)] 异步函数完成 Renderer + egui 初始化后挂上 RAF 闭包链。
#[wasm_bindgen(start)]
pub async fn start() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
tracing_wasm::set_as_global_default();
let canvas: HtmlCanvasElement = /* document.get_element_by_id("game") */;
let renderer = Renderer::new(&canvas).await?;
let egui_ctx = egui::Context::default();
let egui_renderer = egui_wgpu::Renderer::new(/* ... */);
let input = Rc::new(RefCell::new(InputState::default()));
let egui_events = Rc::new(RefCell::new(Vec::<egui::Event>::new()));
let app = Rc::new(RefCell::new(App {
canvas, renderer, egui_ctx, egui_renderer,
input: input.clone(),
egui_events: egui_events.clone(),
state: AppState::Lobby,
lobby_state: LobbyState::default(),
game: None,
last_time_ms, fps_frames: 0, fps_accum: 0.0, fps_display: 0.0,
request_pointer_lock_next: false,
}));
install_event_listeners(&canvas, &document, input, egui_events, app.clone())?;
spawn_raf_loop(app);
Ok(())
}事件路由(install_event_listeners):
clickon canvas → 仅在 InGame 时canvas.request_pointer_lock()pointerlockchangeon document → 写回input.pointer_lockedkeydown/keyupon document → InGame 时映射到InputState;Lobby 时不消费(让 egui 处理文本输入)mousemoveon document:指针锁时累积相机 dx/dy;否则上报egui::Event::PointerMoved(让 Lobby 按钮能接收 hover)mousedown/mouseup:InGame 时写 InputState,Lobby 时转egui::Event::PointerButton
关键修复(commit edae0e6):早期版本只在 InGame 才向 egui 喂事件,导致大厅按钮无法点击。Phase 2 起 mouse 事件在所有 state 下都会推到
egui_events累加器,每帧 drain 入RawInput.events。
pub enum AppState {
Loading, // [Phase 0+] 初始占位,实际未使用
Lobby, // [Phase 2+] 大厅
Connecting, // [Phase 4+] 网络协商 + 区块预载
InGame { paused: bool, chat_open: bool }, // [Phase 6+] 游戏中(可叠加暂停 / 聊天)
Disconnected, // [Phase 6+] 连接断开页面
}
impl AppState {
pub fn is_in_game(&self) -> bool; // 匹配任意 InGame 子状态
pub fn ingame_default() -> Self; // InGame { paused: false, chat_open: false }
}Phase 6 把原 EscMenu / ChatOpen unit variant 合入 InGame 结构变体,让 HUD 与名牌在暂停 / 聊天期间仍可绘制,且两个叠加层可并存。
Connecting 期间不携带子状态结构体——进度信息由 RoomSession::loading_steps()(网络步骤)和 App::preload_state(区块预载步骤)联合提供。
PreloadState 追踪出生点周围 chunk 的加载进度(Phase 5+):
pub struct PreloadState {
pub total: usize, // 期望加载的 chunk 总数 = (2*render_distance+1)^2
pub received: usize, // 已存在于 world.chunks 中的 chunk 数
pub meshed: usize, // 已有 GPU mesh 的 chunk 数
pub active: bool, // 预载进行中
}预载完成条件:received >= total && mesh_jobs.is_empty()。
用 received 而非 meshed 判完成是因为全空气 chunk mesh 后顶点为空,不产生 GPU 记录。mesh_jobs.is_empty() 确保所有已入队 chunk 都被处理过。
Phase 2 起,App 容器只持有跨状态资源(renderer / egui / input / 大厅 UI state)。游戏内运行时持有于 Game 子结构,仅 InGame 时存在:
struct App {
canvas: HtmlCanvasElement,
renderer: Renderer,
egui_ctx: egui::Context,
egui_renderer: egui_wgpu::Renderer,
input: Rc<RefCell<InputState>>,
egui_events: Rc<RefCell<Vec<egui::Event>>>,
state: AppState,
lobby_state: LobbyState,
// —— Connecting 阶段 ——
connecting_mode: GameMode, // Host / Remote / Local
connecting_room_id: String,
connecting_error: Option<String>,
preload_state: Option<PreloadState>, // [Phase 5+] 区块预载进度
disconnect_reason: Option<String>,
game: Option<Game>,
world_session_id: u64, // 每次进入/离开世界递增,用于丢弃旧异步回调
last_time_ms: f64,
fps_frames: u32, fps_accum: f32, fps_display: f32,
request_pointer_lock_next: bool,
}
pub struct Game {
pub server: Rc<RefCell<Server>>, // [Phase 2] Local-Only 持有完整 Server
pub server_inbox: ServerInbox, // [Phase 2] mpsc 服务端侧
pub net: NetEndpoint, // [Phase 2] ::Local;Phase 4 → Host/Remote
pub camera: Camera, // [Phase 1+] 朝向+矩阵;位置由 physics 每帧同步
pub physics: LocalPhysics, // [Phase 3 ✅] Walk/Fly + 分轴碰撞 + 重力
pub hotbar: Hotbar, // [Phase 3 ✅] 9 格快捷栏
pub pending: PendingActions, // [Phase 3 ✅] 挖放 rollback 队列
pub current_hit: Option<RaycastHit>, // [Phase 3 ✅] DDA 命中缓存
pub last_break_at_ms: f64, // [Phase 3 ✅] 连续挖掘冷却
pub mesh_jobs: MeshJobQueue, // [Phase 2]
pub chunk_loader: ChunkLoader, // [Phase 2]
pub frame_clock: FrameClock, // [Phase 2+]
pub settings: AppSettings, // [Phase 6+] 用户设置(曾用名 GameSettings)
pub entity_id: u32, // [Phase 2] 由 Welcome 填充
pub display_name: String, // [Phase 6] 由 lobby 注入
pub host_entity_id: EntityId, // [Phase 6] Host=自己;Remote=Welcome 填
// —— 以下为后续 Phase 引入 ——
pub interp: PlayerInterp, // [Phase 5 ✅]
pub chunk_assembler: ChunkAssembler, // [Phase 5 ✅]
pub remote_players: HashMap<EntityId, RemotePlayerState>, // [Phase 5 ✅]
pub local_input_tick: u32, // [Phase 8] 本地输入序号;Remote 也单调递增
pub input_history: InputHistory, // [Phase 5 ✅] 以 local_input_tick 记录预测位置
pub pending_position_correction: Vec3, // [Phase 8] 中等误差按帧软修正,避免高延迟回弹
pub server_clock_offset_ms: f64, // [Phase 8] 平滑后的 Host 时钟偏移;Pong 半 RTT 校正 + PlayerTick 低权重补充
pub server_clock_synced: bool, // [Phase 8] 第一条时钟样本直接采用,后续指数平滑
pub chat: ChatHistory, // [Phase 6 ✅] 聊天历史(含本地合成的系统消息)
}
/// 用户设置([Phase 6 ✅] 取代 [`GameSettings`];serde JSON 持久化到 localStorage)。
///
/// 开发/调优字段(fly_speed / mesh_budget_ms / min_action_interval_ms)标 `#[serde(skip)]`,
/// 不污染存储,仅在运行时可用。
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppSettings {
pub fov_degrees: f32, // 默认 70.0,范围 30..=110
pub mouse_sensitivity: f32, // 默认 1.0(乘以 BASE_SENSITIVITY_RAD_PER_PIXEL = 0.0025)
pub render_distance: u32, // 默认 6,选项 2/4/6/8/10
pub interp_delay_ms: f32, // 默认 100.0,选项 50 / 100 / 150
pub show_stats: bool, // 默认 true,控制左上角统计面板
#[serde(skip)]
pub fly_speed: f32,
#[serde(skip)]
pub mesh_budget_ms: f32,
#[serde(skip)]
pub min_action_interval_ms: f64,
}
/// 设置 localStorage 持久化。
/// 键 "voxweb.settings.v1",schema 头字段版本 = 1;schema 不匹配视为不可读、回退默认。
pub mod settings_storage {
pub fn load() -> Option<AppSettings>;
pub fn save(settings: &AppSettings);
}
/// [Phase 2+] 固定步长(1/60)累加器:渲染帧的 dt 累加到 `accumulator`,
/// 每次 `consume_logic_step` 扣除一步返回 true;累加上限 0.25s 防止后台 Tab 回前台时风暴。
pub struct FrameClock { /* accumulator: f32, step: f32 */ }Phase 2 不引入
world_view:Local 模式下 mesh 回调直接借server.world.get_block_world。Phase 5 加入 Remote 模式时才有WorldView副本(由 ChunkSnapshot 喂数据)。
进入新世界或离开当前世界时,client 会递增 world_session_id,并清理 Game、区块预载状态、输入事件、性能统计与 Renderer 内的世界 GPU 缓存。OPFS 存档加载等异步任务在回写前必须比对启动时捕获的 world_session_id;若会话已变化,直接丢弃结果,避免“退出再进入”后旧世界的 chunk 或 mesh 混入新世界。
返回大厅统一清空当前存档列表缓存并标记为未加载,让下一帧 Lobby 自动重新读取 OPFS 世界列表。
render_frame 按 app.state 分流:
fn render_frame(app: &Rc<RefCell<App>>) -> Result<(), String> {
let dt = update_clock(app);
let (cw, ch) = sync_canvas_size(...);
match app.borrow().state.clone() {
AppState::InGame => render_game_frame(app, dt, cw, ch),
AppState::Connecting => render_connecting_frame(app, cw, ch), // [Phase 4+]
_ => render_lobby_frame(app, cw, ch),
}
}专用于 Connecting 状态:
poll_net(app)— 推进网络状态机(信令+WebRTC 协商)- drain Server→Client inbox — Remote 端收 Welcome / ChunkSnapshot / BlockUpdate 等消息([Phase 5+])
- 区块预载([Phase 5+]):若
preload_state.active:- Host/Local:调
chunk_loader.update(DEFAULT_SPAWN, ...)生成出生点周围 chunk 并入队网格化 - Remote:仅运行
mesh_jobs.run_until_budget(16ms)(chunk 由上一步的 inbox drain 喂入) - 统计
received(存在于world.chunks)和meshed(有 GPU mesh) - 完成条件
received >= total && mesh_jobs.is_empty()→state = InGame
- Host/Local:调
- 构建步骤列表:
RoomSession::loading_steps()(网络步骤)+ 区块预载步骤 - 渲染 UI:
draw_connecting(steps)+ 暗色清屏 + egui pass
- 取
egui_events累加器 drain 出鼠标事件 → 塞入RawInput.events egui_ctx.run_ui(...)跑大厅 UI(draw_lobby返回Option<LobbyAction>)- 若返回
LobbyAction::StartSinglePlayer { seed }→start_single_player(app, seed):- 用
getrandom抓 8 字节 → u64 seed(输入为空时) Game::new_local(seed, settings)创建 server + mpsc + Camera- 发
ClientMessage::Hello入队 - 置
state = InGame、request_pointer_lock_next = true
- 用
- 编码 lobby Pass:先
Clear暗蓝色背景,再画 egui
fn render_game_frame(app: &Rc<RefCell<App>>, dt: f32, cw: u32, ch: u32) -> Result<(), String> {
// 1. drain Client→Server → Server.handle_message → 推回 outbox
// 2. drain Server→Client → apply_server_message
// (Welcome / ActionAck / BlockUpdate)
// 3. 输入 → 物理 + 动作
// - 鼠标 apply_mouse
// - hotbar_request 消费
// - fly_toggle_pending 切换 Walk/Fly
// - physics.step(get_block, &camera, &input, dt) → camera.position 同步
// - 60Hz 逻辑帧:frame_clock.accumulate + consume → server.tick + PlayerInput 发送
// - DDA raycast → current_hit
// - dispatch_actions(挖放边沿+冷却+乐观记录+发消息)
// - input.reset_delta()
// 4. ChunkLoader 滚动(&mut Server / &mut MeshJobQueue / &mut Renderer)
// 5. mesh_jobs.run_until_budget
// 6. egui HUD:FPS / POS / MODE / CHUNKS / 准星 / Hotbar 9 格
// 7. wgpu 渲染 + present
// - OpaquePass(render_world)
// - SelectionPass(render_selection,current_hit 时才画)
// - egui Pass
Ok(())
}借用顺序(避免 RefCell 二次借用 / borrow_mut 冲突):
- 先
app.borrow_mut()drain mpsc inbox- 后续 ChunkLoader.update 需
&mut Server+&mut Renderer,用结构体解构借出- mesh_jobs.run_until_budget 用
server.borrow()(immutable)+&mut Renderer,与上一步不重叠
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let dt = self.frame_clock.tick();
self.process_inbound(); // 网络入站
self.update_camera(dt); // 即时响应
self.frame_clock.accumulate(dt);
while self.frame_clock.consume_logic_step() {
self.update_logic(LOGIC_DT); // 含 prediction.reconcile
}
self.interp.advance(dt); // 远端玩家插值
self.mesh_jobs.run_until_budget(MESH_BUDGET_MS, &mut self.renderer);
let egui_output = self.build_ui();
self.renderer.render_frame(&self.frame_data(), egui_output);
self.maybe_flush_persistence(); // 每 1 秒触发一次 OPFS 写入(snapshot dirty + 异步 save)
}pub struct Camera {
pub position: glam::Vec3, // [Phase 3] 由 LocalPhysics.eye_position() 每帧同步
pub yaw: f32, // 弧度
pub pitch: f32,
pub fov: f32,
pub aspect: f32,
pub near: f32,
pub far: f32,
}
pub enum CameraMode {
Walk, // [Phase 3 ✅] 受重力、AABB 碰撞
Fly, // [Phase 1+] 无重力、自由飞行
}
impl Camera {
pub fn forward(&self) -> Vec3; // 单位向量(含 pitch)
pub fn forward_horizontal(&self) -> Vec3; // [Phase 3] XZ 面单位向量,丢弃 pitch
pub fn right(&self) -> Vec3; // 水平面右向
pub fn view_matrix(&self) -> Mat4;
pub fn projection_matrix(&self) -> Mat4;
pub fn vp_matrix(&self) -> Mat4;
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sensitivity: f32);
}Camera 不持有移动输入逻辑:位置由 LocalPhysics 每帧 eye_position() 同步覆盖;Fly 移动也归入 physics(Phase 3 删除了 apply_fly_input,统一在 LocalPhysics::step_fly 中实现)。
Phase 3 实装是扁平 InputState:每帧累加按键 / 鼠标事件,按"持续"vs"边沿"两类语义存储;帧末 reset_delta() 清掉所有边沿,保留持续按下状态。WASM 侧浏览器事件经 lib.rs::map_key(KeyboardEvent.code → winit::keyboard::KeyCode)路由到 on_key_down/up。
pub struct InputState {
// —— WASD 持续按下 ——
pub forward: bool, pub backward: bool, pub left: bool, pub right: bool,
// —— 跳跃 / 蹲伏 ——
pub jump_held: bool, // 持续:Fly 上升 / Walk 见 jump_just_pressed
pub jump_just_pressed: bool, // 边沿:Walk 起跳触发
pub sneak: bool, // 持续:Fly 下降 / 后续 Walk 蹲伏
// —— 鼠标按键 ——
pub break_held: bool, pub break_just_pressed: bool, // 0 = 左
pub place_held: bool, pub place_just_pressed: bool, // 2 = 右;1 中键忽略
// —— 边沿事件 ——
pub hotbar_request: Option<u8>, // Digit1..=Digit9 → Some(0..=8)
pub fly_toggle_pending: bool, // 双击空格 250ms 内触发
pub chat_open: bool, pub esc_menu: bool,
// —— 鼠标移动 ——
pub mouse_dx: f32, pub mouse_dy: f32,
pub pointer_locked: bool,
// —— 内部:双击空格判定时间戳(performance.now() ms)——
last_space_press_at_ms: Option<f64>,
}
impl InputState {
pub fn on_key_down(&mut self, key: KeyCode, now_ms: f64);
pub fn on_key_up(&mut self, key: KeyCode);
pub fn on_mouse_down(&mut self, button: u16); // 0/1/2 = 左/中/右
pub fn on_mouse_up(&mut self, button: u16);
pub fn on_mouse_move(&mut self, dx: f32, dy: f32);
/// 帧末调用:清边沿(mouse_dx/dy / *_just_pressed / hotbar_request / fly_toggle_pending / chat_open / esc_menu),
/// 保留持续状态(forward..right / *_held / sneak / pointer_locked)
pub fn reset_delta(&mut self);
}指针锁:进入 InGame 时调用 canvas.request_pointer_lock()(必须由用户手势触发,故"开始游戏"按钮的点击事件中发起)。pointerlockchange 监听器写回 pointer_locked,主循环只在 pointer_locked=true 时消费 mouse_dx/dy 和 WASD(避免后台移动)。ESC 释放。
MeshJobQueue:
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum MeshPriority {
Critical = 0, // 玩家正站立的 chunk
High = 1, // 玩家附近 1 chunk 范围
Medium = 2, // 渲染距离内其它
Low = 3, // 邻居加载触发的重网格化 / 边界 chunk
}
pub struct MeshJobQueue {
queues: [VecDeque<ChunkPos>; 4], // 按 MeshPriority 索引
pending: HashMap<ChunkPos, MeshPriority>, // 防重 / cancel / 优先级升级
}
impl MeshJobQueue {
pub fn enqueue(&mut self, pos: ChunkPos, priority: MeshPriority);
pub fn cancel(&mut self, pos: ChunkPos);
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
/// `now_ms` 注入便于测试与平台抽象,运行期传 `&now_ms`(封装 performance.now())
pub fn run_until_budget(
&mut self,
budget_ms: f32,
server: &Server,
renderer: &mut Renderer,
now_ms: &dyn Fn() -> f64,
) -> MeshRunStats;
}run_until_budget 每次取最高优先级队列 head,从 server.world.chunks.get(&pos) 取出 chunk(若已被卸载就跳过),调 chunk_mesh::generate_with_neighbors(chunk, pos, &|wx,wy,wz| server.world.get_block_world(...)),结果通过 renderer.upload_chunk_mesh 上传。(now_ms() - start) as f32 >= budget_ms 时退出,并返回本批 MeshRunStats 给 HUD。
防重语义:同 chunk 二次
enqueue默认去重;若新优先级高于旧优先级,则从旧队列移除并升级。玩家脚下 chunk 因此能及时进入Critical。
ChunkLoader:
pub struct ChunkLoader {
pub render_distance: i32, // 默认 6
pub unload_buffer: i32, // 常数 3(实际卸载半径 = render_distance + buffer)
pub loaded: HashSet<ChunkPos>,
last_center: Option<ChunkPos>,
}
impl ChunkLoader {
pub fn new(render_distance: u32) -> Self;
pub fn invalidate(&mut self); // 下一次 update 强制重算
/// 返回是否发生了变更(用于调试 / 性能 stat)
pub fn update(
&mut self,
camera_pos: Vec3,
server: &mut Server,
mesh_jobs: &mut MeshJobQueue,
renderer: &mut Renderer,
) -> bool;
}
// 工具函数
pub fn chunk_pos_of(world_pos: Vec3) -> ChunkPos; // div_euclid 处理负坐标
pub fn chebyshev_distance(a: ChunkPos, b: ChunkPos) -> i32;
pub fn priority_for_distance(pos: ChunkPos, center: ChunkPos) -> MeshPriority;
// d == 0 → Critical, d == 1 → High, d >= 2 → Mediumupdate 行为:
- 算出当前 chunk 中心;若与
last_center相同则直接返回 false(chunk 内移动不触发) - 计算期望集合(
render_distance半径切比雪夫方形) - 新增 chunk:
desired - loaded,逐个server.world.ensure_chunk_generated(pos)→mesh_jobs.enqueue(pos, prio) - 对这一批新 chunk 的 4 个水平邻居(已在
loaded中且renderer.has_chunk_mesh(neighbor) == true)以MeshPriority::Low重新入队,使跨区块剔除生效 - 卸载:
loaded中切比雪夫距离> render_distance + unload_buffer的 chunk →server.unload_chunk+mesh_jobs.cancel+renderer.drop_chunk_mesh
借用顺序:ChunkLoader.update 需要
&mut Server,mesh_jobs.run_until_budget 需要&Server。两段按序执行,不重叠。Phase 2 主循环(crates/client/src/lib.rs)严格遵守此顺序。
6.8 physics.rs([Phase 3 ✅],详见 features/physics.md)
LocalPhysics { feet_position, velocity, on_ground, mode }—— Walk 模式:重力(−32 m/s²)、跳跃(8.4 m/s)、lerp 水平加速(HORIZ_ACC=12)、Y/X/Z 分轴碰撞、5cm 地面探测。Fly 模式:直接 position += dir * FLY_SPEED * dt。位置驱动 camera.position = physics.eye_position()。
6.9 raycast.rs([Phase 3 ✅],详见 features/physics.md)
Amanatides & Woo DDA 网格步进,最大射程 6 格。RaycastHit { pos, normal: IVec3, face: Face, distance }——Face 复用 render::vertex::Face。命中条件 block != AIR && properties(block).solid。
Hotbar { items: [BlockID; 9], selected: usize },1-9 键切换(InputState::hotbar_request 边沿)。block_label(id) -> &'static str 供 HUD 显示。
6.11 prediction.rs([Phase 3 ✅]/[Phase 5 完整],详见 networking/prediction.md)
Phase 3 已实装 PendingActions:挖放操作乐观记录 + ActionAck 协调(commit/rollback)。Phase 5 补输入历史缓冲 + PlayerTick 协调(位置预测)。
远端玩家位置插值缓冲区:
pub struct PlayerInterp {
buffers: HashMap<EntityId, RemotePlayerBuffer>,
interp_delay_ms: f32, // 默认 100ms,平衡平滑与延迟
}
pub struct RemotePlayerBuffer {
snapshots: VecDeque<TimedSnapshot>, // 按 server_time_ms 排序
interpolated_pos: Vec3,
interpolated_yaw: f32,
interpolated_pitch: f32,
}
impl PlayerInterp {
pub fn ingest_tick(&mut self, players: &[PlayerSnapshot], server_time_ms: u64);
pub fn advance(&mut self, dt: f32);
pub fn current(&self, entity: EntityId) -> Option<(Vec3, f32, f32)>;
}#[async_trait::async_trait(?Send)]
pub trait WorldStorage {
async fn open(room_id: &str, seed: u64) -> Result<Self, StorageError> where Self: Sized;
async fn list_chunks(&self) -> Result<Vec<ChunkPos>, StorageError>;
async fn load_chunk(&self, pos: ChunkPos) -> Result<Option<Vec<u8>>, StorageError>;
async fn save_chunks(&self, items: Vec<(ChunkPos, Vec<u8>)>) -> Result<(), StorageError>;
async fn quota(&self) -> Option<QuotaInfo>;
}
pub struct OpfsStorage {
root: web_sys::FileSystemDirectoryHandle, // opfs:/voxweb/<world_key>/
chunks_dir: web_sys::FileSystemDirectoryHandle,
world_key: String,
}调用模式:所有方法返回 future。client 在合适时机用 wasm_bindgen_futures::spawn_local(async move { ... }) 启动,完成后通过 futures-channel::oneshot 把结果投递回主循环。chunk 字节流的 encode/decode 由 crates/core/src/chunk.rs 的 encode / decode(palette + RLE)负责,storage 层只处理原始字节。
大厅通过 storage_overview() 同时读取 navigator.storage.estimate() 与世界列表,每个 WorldSummary.used_bytes 由 world.json + chunks/*.bin 文件大小求和得到。游戏内 HUD 不逐帧扫描 OPFS:启动时记录当前世界 chunk 文件大小表,周期保存成功后按本次 encoded chunk 字节数做增量修正。
详见 features/ui.md。模块划分:
ui::lobby— 大厅ui::hud— HUD(坐标、玩家列表、聊天叠层、准星)ui::pause— ESC 菜单ui::chat— 聊天框(输入与历史)ui::players— 远端玩家名牌(特殊:在 3D 空间渲染,需要egui::Painter+ 投影计算)
ui::mod::draw(app) 按 AppState 路由:
pub fn draw(app: &mut App, ctx: &egui::Context) {
match app.state {
AppState::Lobby => lobby::draw(app, ctx),
AppState::Connecting { .. } => connecting::draw(app, ctx),
AppState::InGame { paused, chat_open } => {
hud::draw(app, ctx);
players::draw_nameplates(app, ctx);
if chat_open { chat::draw(app, ctx); }
if paused { pause::draw(app, ctx); }
}
AppState::Disconnected { reason } => disconnected::draw(app, ctx, reason),
}
}- 用户在大厅点击"单人模式"/"创建房间"/"加入房间"
- 创建
Game实例(new_local/new_host/new_remote) app.state = Connecting,开启render_connecting_frame循环- 网络协商(仅 Host/Remote):信令 → 注册 → offer/answer → DataChannel open
- Host:信令注册成功后即视为网络 Connected
- Remote:等 DataChannel 打开后才 Connected
- Local:跳过此步,直接进入区块预载
- 区块预载([Phase 5+] 全部三种模式):网络 Connected 后启动,加载出生点周围
(2*render_distance+1)^2个 chunk:- Host/Local:本地
chunk_loader.update(DEFAULT_SPAWN, ...)生成 + 入队,每帧mesh_jobs.run_until_budget(16ms)网格化 - Remote:先消费 Host bootstrap
ChunkSnapshot;随后使用min(local_render_distance, host_render_distance)作为有效视距,若范围内还有缺失 chunk,则发送ClientMessage::ChunkRequest请求 Host 补齐,收到快照后入队网格化 - 完成条件:
received >= total && mesh_jobs.is_empty()→state = InGame
- Host/Local:本地
- 进入 InGame 后请求指针锁
Remote 模式不会本地生成地形。ChunkLoader 在 Remote 下维护两组集合:
loaded:已经收到完整ChunkSnapshot并写入本地 world 的 chunkrequested:已经通过ChunkRequest发给 Host、正在等待快照的 chunk
Connecting 阶段以出生点为中心补齐有效视距;InGame 阶段每次玩家跨 chunk 边界、渲染距离变化或收到 Host 视距变化,重新计算 (2*effective_render_distance+1)^2 的 desired 集合,只把 loaded / requested 都没有的 chunk 打包进 ChunkRequest。收到 ChunkSnapshot 后调用 mark_loaded(pos),并重网格化该 chunk 与周围邻居。超过 effective_render_distance + unload_buffer 的本地 world chunk 和 GPU mesh 会被卸载。
Game::effective_render_distance() 定义:
- Local/Host:直接使用自己的
settings.render_distance - Remote:使用
min(settings.render_distance, host_render_distance);host_render_distance来自Welcome或后续HostSettings
- ESC 键按下 →
paused = true,释放指针锁 - 再按 ESC 或点击"返回游戏" →
paused = false,请求指针锁
- T 键按下 →
chat_open = true,输入路由到 chat input buffer - Enter 提交 → 发
ClientMessage::Chat,chat_open = false - ESC 取消 →
chat_open = false
- 收到
RoomEvent::Disconnected,渲染断线提示页
pub struct AppSettings {
pub display_name: String,
pub mouse_sensitivity: f32, // 0.1..=5.0
pub fov_degrees: f32, // 30..=110
pub render_distance_chunks: u32, // 2..=10
pub vsync: bool, // 浏览器侧 RAF 自动 vsync,此项仅作占位
pub depth_prepass: bool,
pub interp_delay_ms: f32,
pub show_stats: bool,
}存放:localStorage(轻量配置);不进 OPFS(OPFS 留给世界数据)。
console_error_panic_hook把 Rust panic 输出到浏览器 consoletracing-wasm桥接tracing宏到console.log/console.error- 所有
Err路径必须tracing::error!一次再决定是否传给 UI(避免静默失败) - UI 错误显示:
AppState::Disconnected { reason }+ 大厅角落的 toast(v2)
| 指标 | 目标 |
|---|---|
| WASM 包体积(gz) | < 6MB |
| 启动到大厅可交互 | < 2s(中等网络) |
| 60fps 维持率(中等设备 + 渲染距离 6) | > 95% |
| 内存峰值 | < 512MB(Tab 级别) |
降体积手段:
[profile.release] lto = "fat"、codegen-units = 1、opt-level = "z"- 构建后
wasm-opt -Oz - 不引入大依赖(如
winit已包含很多 web 后端代码,不可避免) - 字符串本地化只内嵌中文(v2 抽英文)
- 多窗口(浏览器 Tab 即窗口)
- 全屏 API(v2,按 F11 触发
requestFullscreen) - 控制器/手柄输入(gamepad API;v2)
- 帧率限制 throttle(依赖浏览器 RAF;提供
vsync占位但不实装) - 不同语言切换(仅中文)
- 启动画面(splash) — 直接显示大厅