Skip to content

244. 综合实战:开放世界(四十五)

Published:

上节实现了登录接口。

image.png image.png

这节把对应的前端界面写一下

进入前端项目

写一下 src/api/login.js

/**
 * 用户登录 API。与 register 共用 Vite 代理 /api → localhost:3000。
 */
function getApiBase() {
  const base = import.meta.env.VITE_API_BASE;
  if (base) {
    return String(base).replace(/\/$/, '');
  }
  return '/api';
}

/**
 * POST /user/login
 * @returns {Promise<{ accessToken: string, user: { id: number, username: string } }>}
 */
export async function loginUser(username, password) {
  const url = `${getApiBase()}/user/login`;
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  });
  let data = {};
  try {
    data = await res.json();
  } catch {
    /* ignore */
  }
  if (!res.ok) {
    const msg =
      data.message ||
      data.error ||
      (typeof data === 'string' ? data : null) ||
      `请求失败 (${res.status})`;
    throw new Error(msg);
  }
  return data;
}

加一下登录的 html:

image.png

<!-- 登录(同注册:HTML + hidden + CSS) -->
<div id="loginPanel" class="register-panel" hidden>
  <div class="settings-content register-content">
    <div class="settings-header">
      <h2>登录</h2>
      <button id="closeLoginBtn" type="button" class="close-btn">关闭 (ESC)</button>
    </div>
    <div class="settings-body">
      <form id="loginForm" class="register-form">
        <div class="settings-item register-field">
          <label for="loginUsername">用户名</label>
          <input id="loginUsername" name="username" type="text" autocomplete="username" required minlength="1" />
        </div>
        <div class="settings-item register-field">
          <label for="loginPassword">密码</label>
          <input id="loginPassword" name="password" type="password" autocomplete="current-password" required minlength="1" />
        </div>
        <div class="register-actions">
          <button type="submit" id="loginSubmitBtn" class="load-btn">登录</button>
        </div>
        <p id="loginMessage" class="register-message" role="status" hidden></p>
      </form>
    </div>
  </div>
</div>

然后在 main.js 加一下相应逻辑:

image.png

image.png

image.png

image.png

完整代码如下:(不用细看,知道做啥的就行)

import './style.css';
import * as THREE from 'three';
import {
    OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import { CSS3DRenderer, CSS2DRenderer } from 'three/examples/jsm/Addons.js';
import { loadCompletePromise } from './loading.js';
import mesh, { characterModel, playerBody, playerHeight, walkSound, setSoundEffectEnabled } from './mesh.js';
import car, { carModel, carBody, stopCarSound, setCarState } from './car.js';
import plane, { planeModel, planeBody, stopPlaneSound, setPlaneState } from './plane.js';
import house, { doorMesh, doorBody, setDoorState } from './house.js';
import person, { personModel, personBody } from './person.js';
import dancingMirrorHut, { updateDancingMirrorHut, dancingMirrorHutPosition } from './dancingMirrorHut.js';
import birds, { updateBirds } from './birds.js';
import { isNearComputer, enterComputerView, exitComputerView } from './computer.js';
import { mapSystem, updateMapMarkers, toggleFullMap } from './map.js';
import { initWeatherSystem, updateWeather, setWeather, WeatherType, getWeatherSystem } from './weather.js';
import { saveGame, loadGame, hasSave, getSaveTimestamp } from './save.js';
import { setPlayerState, startPlayerDance, stopPlayerDance, isDancing } from './mesh.js';
import { registerUser } from './api/register.js';
import { loginUser } from './api/login.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);

scene.add(mesh);
scene.add(car);
scene.add(plane);
scene.add(house);
scene.add(person);
scene.add(dancingMirrorHut);
scene.add(birds);

scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(20, 30, 10);
sun.castShadow = true;
sun.shadow.camera.left = -30;
sun.shadow.camera.right = 30;
sun.shadow.camera.top = 30;
sun.shadow.camera.bottom = -30;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
scene.add(sun);

const width = window.innerWidth;
const height = window.innerHeight;

const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 200);
camera.position.set(0, 1.6, 5);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({
  antialias: true
});
renderer.setSize(width, height)
renderer.shadowMap.enabled = true;

// 初始化天气系统
initWeatherSystem(scene, camera, renderer);

// 背景音乐
const backgroundMusic = new Audio(`${import.meta.env.BASE_URL}秋日私语.mp3`);
backgroundMusic.loop = true;
backgroundMusic.volume = 0.3; // 背景音乐音量设置为30%

// 在用户第一次交互时播放背景音乐(浏览器自动播放限制)
let musicStarted = false;
function startBackgroundMusic() {
  if (!musicStarted) {
    backgroundMusic.play().catch(err => {
      console.log('播放背景音乐失败:', err);
    });
    musicStarted = true;
  }
}

// 监听用户交互事件来启动背景音乐
document.addEventListener('click', startBackgroundMusic, { once: true });
document.addEventListener('keydown', startBackgroundMusic, { once: true });

// 设置面板控制
export let isSettingsOpen = false;
export let isManualOpen = false;
export let isRegisterOpen = false;
export let isLoginOpen = false;

function dismissRegisterPanel() {
  if (!isRegisterOpen) return;
  isRegisterOpen = false;
  const registerPanel = document.getElementById('registerPanel');
  if (registerPanel) registerPanel.hidden = true;
  const registerMsg = document.getElementById('registerMessage');
  if (registerMsg) {
    registerMsg.hidden = true;
    registerMsg.textContent = '';
    registerMsg.className = 'register-message';
  }
}

function dismissLoginPanel() {
  if (!isLoginOpen) return;
  isLoginOpen = false;
  const loginPanel = document.getElementById('loginPanel');
  if (loginPanel) loginPanel.hidden = true;
  const loginMsg = document.getElementById('loginMessage');
  if (loginMsg) {
    loginMsg.hidden = true;
    loginMsg.textContent = '';
    loginMsg.className = 'register-message';
  }
}
let soundEffectEnabled = true;
let backgroundMusicEnabled = true;
let miniMapEnabled = true;

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(() => {});
}

function toggleSettings() {
  const settingsPanel = document.getElementById('settingsPanel');
  if (!settingsPanel) return;

  dismissRegisterPanel();
  dismissLoginPanel();

  isSettingsOpen = !isSettingsOpen;
  settingsPanel.style.display = isSettingsOpen ? 'flex' : 'none';
  
  // 打开设置时更新存档状态提示
  if (isSettingsOpen) {
    const statusEl = document.getElementById('saveStatus');
    if (statusEl && !statusEl.textContent) {
      const ts = getSaveTimestamp();
      statusEl.textContent = ts ? `上次存档: ${new Date(ts).toLocaleString('zh-CN')}` : '暂无存档';
    }
  }
  
  // 当打开设置面板时,退出指针锁定模式
  if (isSettingsOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }

  // 更新当前天气按钮状态
  if (isSettingsOpen) {
    updateWeatherButtonStates();
  }
}

function toggleManual() {
  const manualPanel = document.getElementById('manualPanel');
  if (!manualPanel) return;

  dismissRegisterPanel();
  dismissLoginPanel();

  isManualOpen = !isManualOpen;
  manualPanel.style.display = isManualOpen ? 'flex' : 'none';
  if (isManualOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }
}

function toggleRegister() {
  const registerPanel = document.getElementById('registerPanel');
  if (!registerPanel) return;

  const willOpen = !isRegisterOpen;
  if (willOpen) {
    dismissLoginPanel();
    if (isSettingsOpen) {
      isSettingsOpen = false;
      const settingsPanel = document.getElementById('settingsPanel');
      if (settingsPanel) settingsPanel.style.display = 'none';
    }
    if (isManualOpen) {
      isManualOpen = false;
      const manualPanel = document.getElementById('manualPanel');
      if (manualPanel) manualPanel.style.display = 'none';
    }
  }

  isRegisterOpen = !isRegisterOpen;
  registerPanel.hidden = !isRegisterOpen;

  if (isRegisterOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }

  const registerMsg = document.getElementById('registerMessage');
  if (!isRegisterOpen && registerMsg) {
    registerMsg.hidden = true;
    registerMsg.textContent = '';
    registerMsg.className = 'register-message';
  }
}

function toggleLogin() {
  const loginPanel = document.getElementById('loginPanel');
  if (!loginPanel) return;

  const willOpen = !isLoginOpen;
  if (willOpen) {
    dismissRegisterPanel();
    if (isSettingsOpen) {
      isSettingsOpen = false;
      const settingsPanel = document.getElementById('settingsPanel');
      if (settingsPanel) settingsPanel.style.display = 'none';
    }
    if (isManualOpen) {
      isManualOpen = false;
      const manualPanel = document.getElementById('manualPanel');
      if (manualPanel) manualPanel.style.display = 'none';
    }
  }

  isLoginOpen = !isLoginOpen;
  loginPanel.hidden = !isLoginOpen;

  if (isLoginOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }

  const loginMsg = document.getElementById('loginMessage');
  if (!isLoginOpen && loginMsg) {
    loginMsg.hidden = true;
    loginMsg.textContent = '';
    loginMsg.className = 'register-message';
  }
}

function updateWeatherButtonStates() {
  const weatherButtons = document.querySelectorAll('.weather-btn');
  const weatherSystem = getWeatherSystem();
  const currentWeather = weatherSystem ? weatherSystem.getCurrentWeather() : WeatherType.CLEAR;
  
  weatherButtons.forEach(btn => {
    const weather = btn.getAttribute('data-weather');
    let weatherType;
    
    switch(weather) {
      case 'clear':
        weatherType = WeatherType.CLEAR;
        break;
      case 'rain':
        weatherType = WeatherType.RAIN;
        break;
      case 'snow':
        weatherType = WeatherType.SNOW;
        break;
      case 'fog':
        weatherType = WeatherType.FOG;
        break;
      default:
        weatherType = WeatherType.CLEAR;
    }
    
    btn.classList.toggle('active', weatherType === currentWeather);
  });
}

// 强制退出载具/电脑,回到玩家步行状态(读档时使用)
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);
  }
}

// 初始化设置面板
document.addEventListener('DOMContentLoaded', () => {
  const settingsBtn = document.getElementById('settingsBtn');
  const closeSettingsBtn = document.getElementById('closeSettingsBtn');
  const bgMusicToggle = document.getElementById('bgMusicToggle');
  const soundEffectToggle = document.getElementById('soundEffectToggle');
  const miniMapToggle = document.getElementById('miniMapToggle');
  const weatherButtons = document.querySelectorAll('.weather-btn');
  const settingsPanel = document.getElementById('settingsPanel');
  const miniMap = document.getElementById('miniMap');
  
  // 阻止设置面板内的点击事件冒泡,防止触发指针锁定
  if (settingsPanel) {
    settingsPanel.addEventListener('mousedown', (e) => {
      e.stopPropagation();
    });
    settingsPanel.addEventListener('click', (e) => {
      e.stopPropagation();
    });
  }
  
  if (settingsBtn) {
    settingsBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleSettings();
    });
  }
  
  if (closeSettingsBtn) {
    closeSettingsBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleSettings();
    });
  }
  
  // 背景音乐开关
  if (bgMusicToggle) {
    bgMusicToggle.addEventListener('change', (e) => {
      backgroundMusicEnabled = e.target.checked;
      if (backgroundMusicEnabled && musicStarted) {
        backgroundMusic.play().catch(err => {
          console.log('播放背景音乐失败:', err);
        });
      } else {
        backgroundMusic.pause();
      }
    });
  }
  
  // 音效开关
  if (soundEffectToggle) {
    soundEffectToggle.addEventListener('change', (e) => {
      soundEffectEnabled = e.target.checked;
      setSoundEffectEnabled(soundEffectEnabled);
      // 如果关闭音效,立即停止所有音效
      if (!soundEffectEnabled) {
        stopCarSound();
        stopPlaneSound();
      }
    });
  }
  
  // 小地图开关
  if (miniMapToggle && miniMap) {
    // 初始化小地图显示状态
    miniMap.style.display = miniMapEnabled ? 'flex' : 'none';
    
    miniMapToggle.addEventListener('change', (e) => {
      miniMapEnabled = e.target.checked;
      miniMap.style.display = miniMapEnabled ? 'flex' : 'none';
    });
  }
  
  // 存档/读档按钮
  const saveBtn = document.getElementById('saveGameBtn');
  const loadBtn = document.getElementById('loadGameBtn');
  if (saveBtn) saveBtn.addEventListener('click', () => { doSave(); });
  if (loadBtn) loadBtn.addEventListener('click', () => { doLoad(); });

  // 使用手册
  const manualBtn = document.getElementById('manualBtn');
  const closeManualBtn = document.getElementById('closeManualBtn');
  const manualPanel = document.getElementById('manualPanel');
  if (manualBtn) manualBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleManual(); });
  if (closeManualBtn) closeManualBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleManual(); });
  if (manualPanel) {
    manualPanel.addEventListener('mousedown', (e) => e.stopPropagation());
    manualPanel.addEventListener('click', (e) => e.stopPropagation());
  }

  const registerPanel = document.getElementById('registerPanel');
  const registerBtn = document.getElementById('registerBtn');
  const closeRegisterBtn = document.getElementById('closeRegisterBtn');
  const registerForm = document.getElementById('registerForm');
  if (registerPanel) {
    registerPanel.addEventListener('mousedown', (e) => e.stopPropagation());
    registerPanel.addEventListener('click', (e) => e.stopPropagation());
  }
  if (registerBtn) {
    registerBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleRegister();
    });
  }
  if (closeRegisterBtn) {
    closeRegisterBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleRegister();
    });
  }
  if (registerForm) {
    registerForm.addEventListener('submit', async (e) => {
      e.preventDefault();
      const usernameEl = document.getElementById('registerUsername');
      const passwordEl = document.getElementById('registerPassword');
      const msgEl = document.getElementById('registerMessage');
      const submitBtn = document.getElementById('registerSubmitBtn');
      const username = usernameEl ? usernameEl.value.trim() : '';
      const password = passwordEl ? passwordEl.value : '';
      if (!username || !password) {
        if (msgEl) {
          msgEl.hidden = false;
          msgEl.textContent = '请填写用户名和密码';
          msgEl.className = 'register-message register-error';
        }
        return;
      }
      if (msgEl) {
        msgEl.hidden = true;
        msgEl.textContent = '';
        msgEl.className = 'register-message';
      }
      if (submitBtn) submitBtn.disabled = true;
      try {
        const data = await registerUser(username, password);
        if (msgEl) {
          msgEl.hidden = false;
          const t = data.createdAt
            ? new Date(data.createdAt).toLocaleString('zh-CN')
            : '';
          msgEl.textContent = t
            ? `注册成功:${data.username}(id: ${data.id})· ${t}`
            : `注册成功:${data.username}(id: ${data.id})`;
          msgEl.className = 'register-message register-success';
        }
      } catch (err) {
        if (msgEl) {
          msgEl.hidden = false;
          msgEl.textContent = err instanceof Error ? err.message : '注册失败';
          msgEl.className = 'register-message register-error';
        }
      } finally {
        if (submitBtn) submitBtn.disabled = false;
      }
    });
  }

  const loginPanel = document.getElementById('loginPanel');
  const loginBtn = document.getElementById('loginBtn');
  const closeLoginBtn = document.getElementById('closeLoginBtn');
  const loginForm = document.getElementById('loginForm');
  if (loginPanel) {
    loginPanel.addEventListener('mousedown', (e) => e.stopPropagation());
    loginPanel.addEventListener('click', (e) => e.stopPropagation());
  }
  if (loginBtn) {
    loginBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleLogin();
    });
  }
  if (closeLoginBtn) {
    closeLoginBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleLogin();
    });
  }
  if (loginForm) {
    loginForm.addEventListener('submit', async (e) => {
      e.preventDefault();
      const usernameEl = document.getElementById('loginUsername');
      const passwordEl = document.getElementById('loginPassword');
      const msgEl = document.getElementById('loginMessage');
      const submitBtn = document.getElementById('loginSubmitBtn');
      const username = usernameEl ? usernameEl.value.trim() : '';
      const password = passwordEl ? passwordEl.value : '';
      if (!username || !password) {
        if (msgEl) {
          msgEl.hidden = false;
          msgEl.textContent = '请填写用户名和密码';
          msgEl.className = 'register-message register-error';
        }
        return;
      }
      if (msgEl) {
        msgEl.hidden = true;
        msgEl.textContent = '';
        msgEl.className = 'register-message';
      }
      if (submitBtn) submitBtn.disabled = true;
      try {
        const data = await loginUser(username, password);
        if (data.accessToken) {
          localStorage.setItem('accessToken', data.accessToken);
        }
        if (data.user) {
          localStorage.setItem('user', JSON.stringify(data.user));
        }
        if (msgEl) {
          msgEl.hidden = false;
          const u = data.user;
          msgEl.textContent = u
            ? `登录成功:${u.username}(id: ${u.id})`
            : '登录成功';
          msgEl.className = 'register-message register-success';
        }
      } catch (err) {
        if (msgEl) {
          msgEl.hidden = false;
          msgEl.textContent = err instanceof Error ? err.message : '登录失败';
          msgEl.className = 'register-message register-error';
        }
      } finally {
        if (submitBtn) submitBtn.disabled = false;
      }
    });
  }

  // 天气切换按钮
  weatherButtons.forEach(btn => {
    btn.addEventListener('click', () => {
      const weather = btn.getAttribute('data-weather');
      let weatherType;
      
      switch(weather) {
        case 'clear':
          weatherType = WeatherType.CLEAR;
          break;
        case 'rain':
          weatherType = WeatherType.RAIN;
          break;
        case 'snow':
          weatherType = WeatherType.SNOW;
          break;
        case 'fog':
          weatherType = WeatherType.FOG;
          break;
        default:
          weatherType = WeatherType.CLEAR;
      }
      
      setWeather(weatherType);
      
      // 更新按钮状态
      updateWeatherButtonStates();
    });
  });
});

const css3Renderer = new CSS3DRenderer();
css3Renderer.setSize(width, height);
css3Renderer.domElement.style.position = 'absolute';
css3Renderer.domElement.style.top = '0px';
css3Renderer.domElement.style.left = '0px';
css3Renderer.domElement.style.pointerEvents = 'none';

const css2Renderer = new CSS2DRenderer();
  css2Renderer.setSize(width, height);
  css2Renderer.domElement.style.position = 'absolute';
  css2Renderer.domElement.style.top = '0px';
  css2Renderer.domElement.style.left = '0px';
  css2Renderer.domElement.style.pointerEvents = 'none';
  css2Renderer.domElement.style.zIndex = '1';

document.body.append(renderer.domElement);
document.body.append(css3Renderer.domElement);
document.body.append(css2Renderer.domElement);

// const controls = new OrbitControls(camera, renderer.domElement);

// 车辆视角切换
export let isCarView = false;

// 飞机视角切换
export let isPlaneView = false;

// 电脑视角切换
export let isComputerView = false;

// 对话状态
export let isTalking = false;
export let isTalkingToDancer = false;

// 对话系统
const dialogueData = [
    { player: "你好!", npc: "哦,你好!看起来你是个战士?" },
    { player: "是的,我在执行任务。你坐在这里做什么?", npc: "哈哈,我在思考人生。这个桶很舒服,你要不要也坐坐?" },
    { player: "不了,我还有任务要完成。", npc: "任务?听起来很严肃。不过你知道吗,有时候停下来看看风景也不错。" },
    { player: "也许你说得对...但我得走了。", npc: "好吧,祝你好运!如果累了,随时可以回来找我聊天。" },
    { player: "谢谢!再见!", npc: "再见,战士!" }
];

let dialogueIndex = 0;

const dancerDialogueData = [
    { player: "你好!", npc: "嗨~你也来看镜子呀?" },
    { player: "嗯,你跳得真好。", npc: "谢谢!我天天在这儿练,你要不要也试试?" },
    { player: "我也可以跳吗?", npc: "当然呀,我教你!很简单的。" },
    { player: "好啊,教教我。", npc: "那你准备好哦,按 J 开始跳舞~" }
];

let dancerDialogueIndex = 0;

// 检查是否靠近镜屋女孩
function isNearDancer() {
    if (!characterModel || !dancingMirrorHutPosition) return false;
    const p = characterModel.position;
    const dx = p.x - dancingMirrorHutPosition.x;
    const dz = p.z - dancingMirrorHutPosition.z;
    return Math.sqrt(dx * dx + dz * dz) < 3;
}

// 检查人物是否在车辆附近
function isNearCar() {
    if (!characterModel || !carBody) return false;

    const characterPos = characterModel.position;
    const carPos = carBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - carPos.x, 2) +
        Math.pow(characterPos.z - carPos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 检查人物是否在飞机附近
function isNearPlane() {
    if (!characterModel || !planeBody) return false;

    const characterPos = characterModel.position;
    const planePos = planeBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - planePos.x, 2) +
        Math.pow(characterPos.z - planePos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 检查玩家是否靠近NPC人物
function isNearPerson() {
    if (!characterModel || !personBody) return false;

    const characterPos = characterModel.position;
    const personPos = personBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - personPos.x, 2) +
        Math.pow(characterPos.z - personPos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 更新提示文本(仅显示当前情境下的操作提示)
function updateViewTip() {
    const tipElement = document.getElementById('viewTip');
    if (!tipElement) return;

    if (isComputerView) {
        tipElement.textContent = '按 E 退出';
    } else if (isCarView) {
        tipElement.textContent = '按 X 下车';
    } else if (isPlaneView) {
        if (planeBody && planeBody.position.y > 2.15) {
            tipElement.textContent = '空格上升 Shift下降 · 先降落再按 C 下飞机';
        } else {
            tipElement.textContent = '空格上升 Shift下降 · 按 C 下飞机';
        }
    } else if (isNearCar()) {
        tipElement.textContent = '按 X 上车';
    } else if (isNearPlane()) {
        tipElement.textContent = '按 C 上飞机';
    } else if (isNearPerson()) {
        if (isTalking) {
            tipElement.textContent = dialogueIndex < dialogueData.length ? '按 H 继续 按 K 结束' : '按 H 重新开始 按 K 结束';
        } else {
            tipElement.textContent = '按 H 对话';
        }
    } else if (isDancing) {
        tipElement.textContent = '按 L 结束跳舞';
    } else if (isNearDancer()) {
        if (isTalkingToDancer) {
            if (dancerDialogueIndex < dancerDialogueData.length) {
                tipElement.textContent = '按 H 继续 按 K 结束';
            } else {
                tipElement.textContent = '按 J 开始跳舞 | 按 K 结束对话';
            }
        } else {
            tipElement.textContent = '按 H 对话';
        }
    } else if (isNearComputer(characterModel)) {
        tipElement.textContent = '按 E 使用电脑';
    } else {
        tipElement.textContent = '';
    }
    tipElement.style.display = tipElement.textContent ? 'block' : 'none';
}

// 控制对话框显示和内容
function updateDialogs() {
    const personDialog = document.getElementById('personDialog');
    const playerDialog = document.getElementById('playerDialog');
    
    const inPersonTalk = isTalking && isNearPerson() && !isCarView && !isPlaneView && !isComputerView;
    const inDancerTalk = isTalkingToDancer && isNearDancer() && !isCarView && !isPlaneView && !isComputerView;

    if (inPersonTalk) {
        if (personDialog && playerDialog) {
            if (dialogueIndex < dialogueData.length) {
                personDialog.textContent = dialogueData[dialogueIndex].npc;
                playerDialog.textContent = dialogueData[dialogueIndex].player;
                personDialog.style.display = 'block';
                playerDialog.style.display = 'block';
            } else {
                personDialog.style.display = 'none';
                playerDialog.style.display = 'none';
            }
        }
    } else if (inDancerTalk) {
        const dancerDialog = document.getElementById('dancerDialog');
        if (dancerDialog && playerDialog) {
            if (dancerDialogueIndex < dancerDialogueData.length) {
                dancerDialog.textContent = dancerDialogueData[dancerDialogueIndex].npc;
                playerDialog.textContent = dancerDialogueData[dancerDialogueIndex].player;
                dancerDialog.style.display = 'block';
                playerDialog.style.display = 'block';
            } else {
                dancerDialog.style.display = 'none';
                playerDialog.style.display = 'none';
            }
        }
    } else {
        if (personDialog) personDialog.style.display = 'none';
        const dancerDialog = document.getElementById('dancerDialog');
        if (dancerDialog) dancerDialog.style.display = 'none';
        if (playerDialog) playerDialog.style.display = 'none';
        if (!isNearPerson()) {
            isTalking = false;
            dialogueIndex = 0;
        }
        if (!isNearDancer()) {
            isTalkingToDancer = false;
            dancerDialogueIndex = 0;
        }
    }
}

// 渲染循环
function render() {
    renderer.render(scene, camera);
    css3Renderer.render(scene, camera);
    css2Renderer.render(scene, camera);
    updateViewTip();
    updateDialogs();
    
    // 同步门的视觉和物理状态(保留物理效果)
    if (doorMesh && doorBody) {
        doorMesh.position.copy(doorBody.position);
        doorMesh.quaternion.copy(doorBody.quaternion);
    }
    
    // 更新地图标记和绘制
    updateMapMarkers(characterModel, carModel, planeModel, personModel, camera, isCarView, isPlaneView);
    updateDancingMirrorHut();
    updateBirds();
    mapSystem.update();
    
    // 更新天气系统
    updateWeather();
    
    requestAnimationFrame(render);
}

// 全部加载完成后再显示游戏并开始渲染
loadCompletePromise.then(() => {
    const overlay = document.getElementById('loadingOverlay');
    if (overlay) overlay.classList.add('hidden');
    render();
});

// 窗口大小调整处理
window.addEventListener('resize', () => {
  const width = window.innerWidth;
  const height = window.innerHeight;
  
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  
  renderer.setSize(width, height);
  css3Renderer.setSize(width, height);
  css2Renderer.setSize(width, height);
});

// 监听键盘事件
window.addEventListener('keydown', (event) => {
    if (event.key === 'x' || event.key === 'X') {
        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();

                // 计算车辆左侧方向(前进方向顺时针旋转90度)
                const left = new THREE.Vector3(forward.z, 0, -forward.x);

                // 计算车辆左侧的位置(距离车辆2米)
                const leftOffset = 2;
                const leftX = carPosition.x + left.x * leftOffset;
                const leftZ = carPosition.z + left.z * leftOffset;

                // 更新人物物理体和模型位置
                playerBody.position.set(leftX, playerHeight / 2, leftZ);
                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);
            }
        } else if (isNearCar()) {
            // 只有在车辆附近才能上车
            isCarView = true;
            if (carModel && characterModel) {
                characterModel.remove(camera);

                // 隐藏人物模型
                characterModel.visible = false;

                carModel.add(camera);
                // 相机在车后面,看向车辆前进方向
                camera.position.set(0, 3, -6);
                camera.rotation.set(-0.1, Math.PI, 0); // 稍微向下看约6度,主要朝向前方
            }
        }
    } else if (event.key === 'c' || event.key === 'C') {
        if (isPlaneView) {
            // 在飞机视角时,需要先降落再下飞机
            if (planeModel && characterModel && planeBody && playerBody) {
                const planeHeight = planeBody.position.y;
                const groundHeight = 1.15; // 飞机在地面时的高度

                // 如果飞机在空中(高于地面1米以上),提示需要先降落
                if (planeHeight > groundHeight + 1) {
                    return;
                }

                // 飞机在地面或接近地面时,可以下飞机
                isPlaneView = false;
                stopPlaneSound(); // 停止开飞机音效
                planeModel.remove(camera);

                // 显示人物模型
                characterModel.visible = true;

                // 将人物放置在飞机左边
                const planePosition = planeBody.position;

                // 获取飞机的前进方向
                const forward = new THREE.Vector3();
                planeModel.getWorldDirection(forward);
                forward.y = 0;
                forward.normalize();

                // 计算飞机左侧方向(前进方向顺时针旋转90度)
                const left = new THREE.Vector3(forward.z, 0, -forward.x);

                // 计算飞机左侧的位置(距离飞机2米)
                const leftOffset = 2;
                const leftX = planePosition.x + left.x * leftOffset;
                const leftZ = planePosition.z + left.z * leftOffset;

                // 更新人物物理体和模型位置
                playerBody.position.set(leftX, playerHeight / 2, leftZ);
                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);
            }
        } else if (isNearPlane()) {
            // 只有在飞机附近才能上飞机
            isPlaneView = true;
            if (planeModel && characterModel) {
                characterModel.remove(camera);

                // 隐藏人物模型
                characterModel.visible = false;

                planeModel.add(camera);
                // 相机在飞机后面,看向飞机前进方向
                camera.position.set(0, 3, -6);
                camera.rotation.set(-0.1, Math.PI, 0); // 稍微向下看约6度,主要朝向前方
            }
        }
    } else if (event.key === 'e' || event.key === 'E') {
        if (isComputerView) {
            // 退出电脑模式
            isComputerView = false;
            exitComputerView(camera, characterModel, css3Renderer);
        } else if (isNearComputer(characterModel)) {
            // 进入电脑模式
            isComputerView = true;
            enterComputerView(camera, scene, css3Renderer, characterModel);
        }
    } else if (event.key === 'h' || event.key === 'H') {
        if (isNearPerson() && !isNearDancer() && !isPlaneView && !isCarView && !isComputerView) {
            if (!isTalking) {
                isTalking = true;
                dialogueIndex = 0;
            } else {
                if (dialogueIndex < dialogueData.length - 1) {
                    dialogueIndex++;
                } else {
                    dialogueIndex = 0;
                }
            }
        } else if (isNearDancer() && !isPlaneView && !isCarView && !isComputerView) {
            if (!isTalkingToDancer) {
                isTalkingToDancer = true;
                dancerDialogueIndex = 0;
            } else {
                if (dancerDialogueIndex < dancerDialogueData.length - 1) {
                    dancerDialogueIndex++;
                } else if (dancerDialogueIndex < dancerDialogueData.length) {
                    dancerDialogueIndex = dancerDialogueData.length; // 对话结束,显示「按 J 开始跳舞」
                } else {
                    dancerDialogueIndex = 0; // 再按 H 重新开始
                }
            }
        }
    } else if (event.key === 'j' || event.key === 'J') {
        // J 键:在女孩对话完成后开始跳舞
        if (
            isNearDancer() &&
            !isPlaneView && !isCarView && !isComputerView &&
            isTalkingToDancer &&
            dancerDialogueIndex >= dancerDialogueData.length
        ) {
            startPlayerDance();
        }
    } else if (event.key === 'l' || event.key === 'L') {
        // L 键:结束跳舞
        if (isDancing) {
            stopPlayerDance();
        }
    } else if (event.key === 'k' || event.key === 'K') {
        if (isNearPerson() && isTalking && !isPlaneView && !isCarView && !isComputerView) {
            isTalking = false;
            dialogueIndex = 0;
        }
        if (isNearDancer() && isTalkingToDancer && !isPlaneView && !isCarView && !isComputerView) {
            isTalkingToDancer = false;
            dancerDialogueIndex = 0;
        }
    } else if (event.key === 'm' || event.key === 'M') {
        // M 键处理:切换全屏地图(在非电脑模式下)
        if (!isComputerView) {
            toggleFullMap();
        }
    } else if (event.key === '1') {
        // 数字键1:晴天
        setWeather(WeatherType.CLEAR);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '2') {
        // 数字键2:雨天
        setWeather(WeatherType.RAIN);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '3') {
        // 数字键3:雪天
        setWeather(WeatherType.SNOW);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '4') {
        // 数字键4:雾天
        setWeather(WeatherType.FOG);
        if (isSettingsOpen) updateWeatherButtonStates();
    } 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();
    } else if (event.key === '?' || event.key === '?') {
        toggleManual();
    } else if (event.key === 'p' || event.key === 'P') {
        if (!isComputerView) toggleSettings();
    } else if (event.key === 'Escape') {
        if (isManualOpen) toggleManual();
        else if (isSettingsOpen) toggleSettings();
        else if (isLoginOpen) toggleLogin();
        else if (isRegisterOpen) toggleRegister();
    }
});

export { camera }

测一下:

2026-04-05 23.34.22.gif

登录成功。

总结

这节做了前端集成登录功能

下节开始就可以做 websocket 多人同时在线的功能了。

评论