Skip to content

232. 综合实战:开放世界(三十三)

Published:

现在没有存档功能,当你探索开放世界一段时间,刷新页面,一切就还原了,这样体验不好。

这节我们加上存档、读档功能。

创建 src/save.js

/**
 * 存档系统 - 使用 localStorage 保存和加载游戏状态
 */

import { WeatherType } from './weather.js';

const SAVE_KEY = 'open-world-save';
const SAVE_VERSION = 1;

/**
 * 收集当前游戏状态
 */
export function getGameState(getters) {
  const { playerBody, carBody, planeBody, doorBody, characterModel } = getters;
  const state = {
    version: SAVE_VERSION,
    timestamp: Date.now(),
    player: null,
    car: null,
    plane: null,
    door: null,
    weather: null,
    settings: getters.settings ? getters.settings() : null
  };

  if (playerBody) {
    const pos = playerBody.position;
    const vel = playerBody.velocity;
    state.player = {
      x: pos.x, y: pos.y, z: pos.z,
      rotY: characterModel ? characterModel.rotation.y : 0,
      vx: vel.x, vy: vel.y, vz: vel.z
    };
  }

  if (carBody) {
    const pos = carBody.position;
    const quat = carBody.quaternion;
    const vel = carBody.velocity;
    state.car = {
      x: pos.x, y: pos.y, z: pos.z,
      qx: quat.x, qy: quat.y, qz: quat.z, qw: quat.w,
      vx: vel.x, vy: vel.y, vz: vel.z
    };
  }

  if (planeBody) {
    const pos = planeBody.position;
    const quat = planeBody.quaternion;
    const vel = planeBody.velocity;
    state.plane = {
      x: pos.x, y: pos.y, z: pos.z,
      qx: quat.x, qy: quat.y, qz: quat.z, qw: quat.w,
      vx: vel.x, vy: vel.y, vz: vel.z
    };
  }

  if (doorBody) {
    const pos = doorBody.position;
    const quat = doorBody.quaternion;
    state.door = {
      x: pos.x, y: pos.y, z: pos.z,
      qx: quat.x, qy: quat.y, qz: quat.z, qw: quat.w
    };
  }

  if (getters.weather) {
    state.weather = getters.weather();
  }

  return state;
}

/**
 * 应用存档状态到游戏
 */
export function applyGameState(state, setters) {
  if (!state || state.version !== SAVE_VERSION) return false;

  const { setPlayerState, setCarState, setPlaneState, setDoorState, setWeather, setSettings } = setters;

  if (state.player && setPlayerState) {
    setPlayerState(state.player);
  }
  if (state.car && setCarState) {
    setCarState(state.car);
  }
  if (state.plane && setPlaneState) {
    setPlaneState(state.plane);
  }
  if (state.door && setDoorState) {
    setDoorState(state.door);
  }
  if (state.weather && setWeather) {
    const weatherType = Object.values(WeatherType).includes(state.weather)
      ? state.weather
      : WeatherType.CLEAR;
    setWeather(weatherType);
  }
  if (state.settings && setSettings) {
    setSettings(state.settings);
  }

  return true;
}

/**
 * 保存到 localStorage
 */
export function saveGame(getters) {
  try {
    const state = getGameState(getters);
    localStorage.setItem(SAVE_KEY, JSON.stringify(state));
    return { success: true, timestamp: state.timestamp };
  } catch (e) {
    console.error('存档失败:', e);
    return { success: false };
  }
}

/**
 * 从 localStorage 加载
 */
export function loadGame(setters) {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return { success: false, reason: '无存档' };
    const state = JSON.parse(raw);
    const ok = applyGameState(state, setters);
    return { success: ok, timestamp: state.timestamp };
  } catch (e) {
    console.error('读档失败:', e);
    return { success: false, reason: '存档损坏' };
  }
}

/**
 * 检查是否有存档
 */
export function hasSave() {
  return !!localStorage.getItem(SAVE_KEY);
}

/**
 * 获取存档时间
 */
export function getSaveTimestamp() {
  try {
    const raw = localStorage.getItem(SAVE_KEY);
    if (!raw) return null;
    const state = JSON.parse(raw);
    return state.timestamp || null;
  } catch {
    return null;
  }
}

封装下获取玩家位置、汽车位置、飞机位置、门的旋转角度等信息的方法。

还有从 localStorage 读写的方法。

然后在 mesh.js 里加一下设置玩家状态的方法:

image.png

// 存档系统:设置玩家状态
export function setPlayerState({ x, y, z, rotY, vx, vy, vz }) {
  if (!playerBody) return;
  playerBody.position.set(x, y, z);
  playerBody.velocity.set(vx ?? 0, vy ?? 0, vz ?? 0);
  if (characterModel) {
    characterModel.rotation.y = rotY ?? 0;
  }
}

很明显,这个是读档用的。

同样的方式加一下其他物体的数据设置的方法:

house.js

image.png

export function setDoorState({ x, y, z, qx, qy, qz, qw }) {
  doorBody.position.set(x, y, z);
  doorBody.quaternion.set(qx, qy, qz, qw);
  doorBody.velocity.set(0, 0, 0);
  doorBody.angularVelocity.set(0, 0, 0);
}

plane.js

image.png

export function setPlaneState({ x, y, z, qx, qy, qz, qw, vx, vy, vz }) {
  planeBody.position.set(x, y, z);
  planeBody.quaternion.set(qx, qy, qz, qw);
  planeBody.velocity.set(vx ?? 0, vy ?? 0, vz ?? 0);
  planeBody.angularVelocity.set(0, 0, 0);
  if (planeModel) {
    planeModel.position.copy(planeBody.position);
    planeModel.quaternion.copy(planeBody.quaternion);
  }
}

car.js

image.png


export function setCarState({ x, y, z, qx, qy, qz, qw, vx, vy, vz }) {
  carBody.position.set(x, y, z);
  carBody.quaternion.set(qx, qy, qz, qw);
  carBody.velocity.set(vx ?? 0, vy ?? 0, vz ?? 0);
  carBody.angularVelocity.set(0, 0, 0);
  if (carModel) {
    carModel.position.copy(carBody.position);
    carModel.position.y -= carSize.height / 2;
    carModel.quaternion.copy(carBody.quaternion);
  }
}

这样,读档的数据就可以设置到这些物体了。

除了这些之外,设置也要存档:

在 main.js 加一下设置的读取、应用:

image.png

function getSettings() {
  return { soundEffectEnabled, backgroundMusicEnabled, miniMapEnabled };
}
function setSettingsFromSave(s) {
  if (!s) return;
  soundEffectEnabled = s.soundEffectEnabled !== false;
  backgroundMusicEnabled = s.backgroundMusicEnabled !== false;
  miniMapEnabled = s.miniMapEnabled !== false;
  setSoundEffectEnabled(soundEffectEnabled);
  const bgMusicEl = document.getElementById('bgMusicToggle');
  const soundEl = document.getElementById('soundEffectToggle');
  const miniMapEl = document.getElementById('miniMapToggle');
  const miniMapEl2 = document.getElementById('miniMap');
  if (bgMusicEl) bgMusicEl.checked = backgroundMusicEnabled;
  if (soundEl) soundEl.checked = soundEffectEnabled;
  if (miniMapEl) miniMapEl.checked = miniMapEnabled;
  if (miniMapEl2) miniMapEl2.style.display = miniMapEnabled ? 'flex' : 'none';
  if (!backgroundMusicEnabled && musicStarted) backgroundMusic.pause();
  else if (backgroundMusicEnabled && musicStarted) backgroundMusic.play().catch(() => {});
}

还有打开设置弹窗的时候,要更新存档的时间:

image.png

  // 打开设置时更新存档状态提示
  if (isSettingsOpen) {
    const statusEl = document.getElementById('saveStatus');
    if (statusEl && !statusEl.textContent) {
      const ts = getSaveTimestamp();
      statusEl.textContent = ts ? `上次存档: ${new Date(ts).toLocaleString('zh-CN')}` : '暂无存档';
    }
  }

我们限制只有下车、下飞机后才能存档。

所以如果当前实在其他状态,比如开车、开飞机,需要把飞机、车的状态重置下:

image.png

// 强制退出载具/电脑,回到玩家步行状态(读档时使用)
function forceExitToPlayer() {
  if (isComputerView) {
    isComputerView = false;
    exitComputerView(camera, characterModel, css3Renderer);
  }
  if (isCarView) {
    isCarView = false;
    stopCarSound();
    if (carModel && characterModel && carBody && playerBody) {
      carModel.remove(camera);
      characterModel.visible = true;
      const carPosition = carBody.position;
      const forward = new THREE.Vector3();
      carModel.getWorldDirection(forward);
      forward.y = 0;
      forward.normalize();
      const left = new THREE.Vector3(forward.z, 0, -forward.x);
      const leftOffset = 2;
      playerBody.position.set(
        carPosition.x + left.x * leftOffset,
        playerHeight / 2,
        carPosition.z + left.z * leftOffset
      );
      playerBody.velocity.set(0, 0, 0);
      characterModel.position.copy(playerBody.position);
      characterModel.position.y -= playerHeight / 2;
      characterModel.rotation.y = carModel.rotation.y;
      characterModel.add(camera);
      camera.position.set(0, 1.5, 2.5);
      camera.rotation.set(0, 0, 0);
      camera.up.set(0, 1, 0);
    }
  }
  if (isPlaneView) {
    isPlaneView = false;
    stopPlaneSound();
    if (planeModel && characterModel && planeBody && playerBody) {
      planeModel.remove(camera);
      characterModel.visible = true;
      const planePosition = planeBody.position;
      const forward = new THREE.Vector3();
      planeModel.getWorldDirection(forward);
      forward.y = 0;
      forward.normalize();
      const left = new THREE.Vector3(forward.z, 0, -forward.x);
      const leftOffset = 2;
      playerBody.position.set(
        planePosition.x + left.x * leftOffset,
        playerHeight / 2,
        planePosition.z + left.z * leftOffset
      );
      playerBody.velocity.set(0, 0, 0);
      characterModel.position.copy(playerBody.position);
      characterModel.position.y -= playerHeight / 2;
      characterModel.rotation.y = planeModel.rotation.y;
      characterModel.add(camera);
      camera.position.set(0, 1.5, 2.5);
      camera.rotation.set(0, 0, 0);
      camera.up.set(0, 1, 0);
    }
  }
  isTalking = false;
  dialogueIndex = 0;
}

虽然代码比较多,但就是根据不同的状态,把汽车、飞机状态的重置。

然后加一下存单、读档功能:

image.png

// 存档
function doSave() {
  if (isCarView || isPlaneView || isComputerView) {
    showSaveStatus('请先下车/下飞机/退出电脑后再存档', 'warning');
    return;
  }
  if (!confirm('确定要存档吗?')) return;
  const getters = {
    playerBody,
    carBody,
    planeBody,
    doorBody,
    characterModel,
    weather: () => getWeatherSystem()?.getCurrentWeather(),
    settings: getSettings
  };
  const result = saveGame(getters);
  if (result.success) {
    const time = new Date(result.timestamp).toLocaleString('zh-CN');
    showSaveStatus(`存档成功 (${time})`, 'success');
  } else {
    showSaveStatus('存档失败', 'error');
  }
}

// 读档
function doLoad() {
  if (!hasSave()) {
    showSaveStatus('暂无存档', 'error');
    return;
  }
  if (!confirm('确定要读档吗?当前进度将被覆盖。')) return;
  forceExitToPlayer();
  const setters = {
    setPlayerState,
    setCarState,
    setPlaneState,
    setDoorState,
    setWeather,
    setSettings: setSettingsFromSave
  };
  const result = loadGame(setters);
  if (result.success) {
    if (isSettingsOpen) updateWeatherButtonStates();
    const time = result.timestamp ? new Date(result.timestamp).toLocaleString('zh-CN') : '';
    showSaveStatus(`读档成功 (存档于 ${time})`, 'success');
  } else {
    showSaveStatus(result.reason || '读档失败', 'error');
  }
}

function showSaveStatus(msg, type = 'info') {
  // 设置面板内的状态
  const statusEl = document.getElementById('saveStatus');
  if (statusEl) {
    statusEl.textContent = msg;
    statusEl.className = 'save-status save-status-' + (type || 'info');
    if (msg) setTimeout(() => { statusEl.textContent = ''; }, 3000);
  }
  // 屏幕中央 Toast 提示(始终可见)
  const toast = document.getElementById('saveLoadToast');
  if (toast && msg) {
    toast.textContent = msg;
    toast.className = 'save-load-toast show ' + (type || 'info');
    clearTimeout(showSaveStatus._toastTimer);
    showSaveStatus._toastTimer = setTimeout(() => {
      toast.classList.remove('show');
    }, 2500);
  }
}

image.png

  // 存档/读档按钮
  const saveBtn = document.getElementById('saveGameBtn');
  const loadBtn = document.getElementById('loadGameBtn');
  if (saveBtn) saveBtn.addEventListener('click', () => { doSave(); });
  if (loadBtn) loadBtn.addEventListener('click', () => { doLoad(); });

提示也更新下:

image.png

image.png

else if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 's') {
    event.preventDefault();
    doSave();
} else if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'l') {
    event.preventDefault();
    doLoad();
}

存档、读档快捷键,就是从 localStorage 中读取、写入玩家等的状态数据。

最后,更新下 html,我们加上存档、读档的提示 ui:

image.png

<div id="saveLoadToast" class="save-load-toast"></div>

以及设置面板里也加一下存档、读档按钮:

image.png

<div class="settings-section">
    <h3>存档</h3>
    <div class="save-load-buttons">
      <button id="saveGameBtn" class="save-btn">存档 (Ctrl+Shift+S)</button>
      <button id="loadGameBtn" class="load-btn">读档 (Ctrl+Shift+L)</button>
    </div>
    <div id="saveStatus" class="save-status"></div>
</div>

最后加一下对应样式:

image.png

/* 存档/读档提示 Toast */
.save-load-toast {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 16px 32px;
  background-color: rgba(0, 0, 0, 0.85);
  color: white;
  border-radius: 8px;
  font-size: 18px;
  z-index: 9999;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.25s ease;
  text-align: center;
  max-width: 90%;
}

.save-load-toast.show {
  opacity: 1;
}

.save-load-toast.success {
  border: 2px solid #38a169;
  color: #9ae6b4;
}

.save-load-toast.error {
  border: 2px solid #e53e3e;
  color: #feb2b2;
}

.save-load-toast.warning {
  border: 2px solid #d69e2e;
  color: #faf089;
}
/* 存档按钮 */
.save-load-buttons {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.save-btn,
.load-btn {
  padding: 12px 20px;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.3s;
}

.save-btn {
  background: #38a169;
  color: white;
}

.save-btn:hover {
  background: #48bb78;
  transform: translateY(-2px);
}

.load-btn {
  background: #3182ce;
  color: white;
}

.load-btn:hover {
  background: #4299e1;
  transform: translateY(-2px);
}

.save-status {
  margin-top: 10px;
  font-size: 14px;
  color: #a0aec0;
  min-height: 20px;
}

试一下:

存档:

2026-03-01 22.10.39.gif

读档:

2026-03-01 22.11.15.gif

车飞机内存档:

2026-03-01 22.11.55.gif

2026-03-01 22.12.16.gif

2026-03-01 22.12.39.gif

没啥问题,这样存档读档功能就完成了。

也可以通过设置面板这里来存档、读档:

image.png

对了,为了避免存档和按 s 后退冲突,这里要过滤下:

image.png

window.addEventListener('keydown', (e) => {
  const key = e.key.toLowerCase();
  // 忽略存档快捷键,避免触发 S 键向后移动
  if (e.ctrlKey && e.shiftKey && key === 's') return;
  if (key === ' ') {
    keyPressed.space = true;
  } else if (key in keyPressed) {
    keyPressed[key] = true;
  }
});

window.addEventListener('keyup', (e) => {
  const key = e.key.toLowerCase();
  // 释放 S 时若为存档快捷键,确保清除移动状态
  if (e.ctrlKey && e.shiftKey && key === 's') {
    keyPressed.s = false;
    return;
  }
  if (key === ' ') {
    keyPressed.space = false;
  } else if (key in keyPressed) {
    keyPressed[key] = false;
  }
});

案例代码上传了小册仓库

总结

这节我们加上了存档、读档功能。

存档会拿到玩家、车、飞机的状态,保存到 localStorage

而读档则是从 localStorage 里取这些数据,设置到玩家、车、飞机

整体思路还是挺清晰的。

难点在于如果玩家在车上、飞机上读档,需要设置下车、飞机的状态。

评论