现在没有存档功能,当你探索开放世界一段时间,刷新页面,一切就还原了,这样体验不好。
这节我们加上存档、读档功能。
创建 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 里加一下设置玩家状态的方法:

// 存档系统:设置玩家状态
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

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

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

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 加一下设置的读取、应用:

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(() => {});
}
还有打开设置弹窗的时候,要更新存档的时间:

// 打开设置时更新存档状态提示
if (isSettingsOpen) {
const statusEl = document.getElementById('saveStatus');
if (statusEl && !statusEl.textContent) {
const ts = getSaveTimestamp();
statusEl.textContent = ts ? `上次存档: ${new Date(ts).toLocaleString('zh-CN')}` : '暂无存档';
}
}
我们限制只有下车、下飞机后才能存档。
所以如果当前实在其他状态,比如开车、开飞机,需要把飞机、车的状态重置下:

// 强制退出载具/电脑,回到玩家步行状态(读档时使用)
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;
}
虽然代码比较多,但就是根据不同的状态,把汽车、飞机状态的重置。
然后加一下存单、读档功能:

// 存档
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);
}
}

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


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:

<div id="saveLoadToast" class="save-load-toast"></div>
以及设置面板里也加一下存档、读档按钮:

<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>
最后加一下对应样式:

/* 存档/读档提示 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;
}
试一下:
存档:

读档:

车飞机内存档:



没啥问题,这样存档读档功能就完成了。
也可以通过设置面板这里来存档、读档:

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

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 里取这些数据,设置到玩家、车、飞机
整体思路还是挺清晰的。
难点在于如果玩家在车上、飞机上读档,需要设置下车、飞机的状态。