☰
AI辅助搭建Three.js+WebSocket多人联机开放世界原型:从零到联机跑通
2026/10/14 3:47:12 网站建设 项目流程

之前在浏览器里折腾 3D 项目时,遇到过太多“本地能跑、一上网络就断”“架构图很饱满、代码一写就瘫”的尴尬局面。这次用 AI 辅助从零搭了一个开放世界多人在线原型,项目代号 aethercraft,目前完成度大概 10%,但基础架构已经立住了:浏览器直接打开就能进,双开窗口联机没有明显延迟,服务端广播和客户端渲染各司其职。这篇文章会把整个搭建过程拆开讲清楚,从技术选型到服务端代码、从 Three.js 场景初始化到玩家移动同步,再到常见报错排查和工程建议。如果你也想在 2026 年把 AI 聊天框变成半个前端架构师,或者想给自己的 Web 游戏项目搭一个可联机的地基,这篇可以直接照着做。

1. 背景与项目定位

1.1 aethercraft 是一个什么样的项目

aethercraft 是一个运行在浏览器里的开放世界游戏原型,目标是做成“打开浏览器就能玩”的多人沙盒游戏。与传统的客户端游戏不同,它不需要安装 Steam、不需要下载几个 G 的资源包,只要服务端启动,玩家输入网址就能进入一个可漫游、可互动的三维世界。

“开放世界”这个词听起来很大,但在这个项目里,它被拆成了几个朴素的核心能力:

  • 一个 3D 场景,玩家可以在其中自由移动。
  • 多个玩家同时在线,彼此能看到对方的位置和动作。
  • 玩家之间可以发送简单消息,验证网络通道是通的。
  • 服务端保存并广播玩家的状态,保证所有人看到的世界基本一致。

目前项目还没有地形生成、也没有完整的方块建造系统,所以“完成度 10%”并不是谦虚,而是事实。这 10% 恰好是最关键的地基:渲染循环、网络同步、玩家实体、消息协议。只要这层地基稳固,后面往上面填充玩法就会顺畅很多。

1.2 为什么用 AI 辅助开发

写这个项目的初衷,是想测试一条更高效的开发路径:用 AI 生成代码骨架,由人工负责架构决策和正确性校验。在传统开发流程里,从零搭建一个 Three.js 项目加上 WebSocket 联机服务,至少要处理十几个容易踩坑的细节,比如模块引入方式、坐标同步时机、视角锁定 API 在不同浏览器里的差异。这些工作很适合让 AI 先产出原型,再进行测试和修正。

实际体验下来,AI 在以下环节确实省了不少时间:

  • 生成 Three.js 场景初始化代码,包括渲染器、相机、灯光。
  • 绘制 WebSocket 服务端的基本通信逻辑。
  • 写出浏览器端接收消息并更新其他玩家位置的处理函数。
  • 快速定位控制台报错,给出修改建议。

但 AI 生成的内容并不能直接上线。它不知道你的项目目录结构,不知道你已经用了哪个版本的 Three.js,也不会替你考虑网络延迟和状态同步策略。每一次 AI 生成代码之后,都要经历“读代码 - 理解逻辑 - 运行验证 - 修复问题”的闭环。AI 的角色更像是结对编程里的搭档,而不是无脑的代码生成器。

1.3 完成度 10% 意味着什么

10% 意味着我们有了一个可以继续生长的骨架,但离“游戏”还很远。具体来说,当前已经完成的部分包括:

  • 服务端启动 WebSocket 网关,支持玩家加入、离开、位置广播。
  • 浏览器端加载 3D 场景,玩家可以使用键盘和鼠标巡视世界。
  • 多个浏览器窗口同时连接时,彼此能看到对方的移动。
  • 简单的聊天消息可以在玩家之间实时传递。

尚未完成的部分包括:

  • 无限地形生成与区块加载。
  • 方块放置、破坏与实时同步。
  • 碰撞检测与重力系统。
  • 玩家模型动画与交互动作。
  • 服务端授权、持久化存档和性能优化。

这个阶段的核心目标不是“好玩”,而是“跑通”。一个跑不通的华丽设计,不如一个能稳定运行的基础原型有价值。

2. 技术选型与环境准备

2.1 整体架构设计

aethercraft 采用经典的前后端分离结构,浏览器端负责 3D 渲染和玩家输入,Node.js 服务端负责连接管理和状态广播。通信协议使用 WebSocket,因为它是浏览器原生支持的二进制全双工通道,适合高频、低延迟的实时交互。

大致数据流向如下:

  • 浏览器玩家按下 WASD 键,本地角色移动。
  • 浏览器通过 WebSocket 向服务端发送玩家的最新坐标和朝向。
  • 服务端收到消息后,把这条状态广播给其他所有在线玩家。
  • 其他玩家的浏览器收到广播,同步更新对应角色的位置。

这个模型也叫“状态同步”的一种简化形式。它不追求绝对的物理一致,也不做服务器的权威回滚,适合原型开发和中小规模并发场景。

2.2 技术栈说明

本项目没有刻意选择冷门技术,全部是社区成熟、文档丰富的方案:

  • 前端 3D 渲染:Three.js
  • 服务端运行环境:Node.js
  • 网络通信:WebSocket,使用 ws 库
  • 前端模块打包与开发服务器:Vite
  • 语言:JavaScript,统一前后端

之所以选择 JavaScript 而不是 TypeScript,是为了让 AI 生成代码时减少类型标注带来的干扰。原型阶段先求快,后续如果需要工程化,再迁移到 TypeScript 也不迟。

版本方面不需要完全死板对齐,2026 年常见的 Three.js 版本和 Vite 版本都适用。只要你安装依赖时使用最新稳定版,大部分 API 与代码示例兼容。

2.3 本地环境准备

在开始之前,请确认本机环境满足以下条件:

  • 已安装 Node.js 18 以上版本(LTS 或更新版本均可)。
  • 拥有一个终端,能够执行 npm 命令。
  • 浏览器建议使用 Chrome、Edge 或 Firefox 最新版。
  • 具备基本的 JavaScript 和 Node.js 知识。

如果你的环境中 Node.js 版本比较旧,建议先升级。WebSocket 和 ES Module 相关的语法在旧版本 Node 里可能会有兼容性问题。

2.4 项目目录结构

项目采用前后端同仓管理,目录结构如下:

aethercraft/ ├── package.json ├── server/ │ └── index.js ├── public/ │ ├── index.html │ └── main.js └── README.md

这个结构足够简单,同时保留了前后端分离的清晰度。后续如果项目规模变大,可以在 public 下继续拆 moduls、assets 等子目录。

3. 基础架构搭建:从零启动一个可连机的开放世界原型

3.1 创建项目并安装依赖

先在终端中创建项目目录并初始化 package.json:

mkdir aethercraft cd aethercraft npm init -y

接着安装依赖:

npm install express ws npm install --save-dev vite

这里使用 express 作为静态文件服务,ws 提供 WebSocket 支持,vite 作为开发服务器和前端资源打包器。安装完成后,package.json 大致长这样:

{ "name": "aethercraft", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "vite", "server": "node server/index.js" }, "dependencies": { "express": "^4.19.2", "ws": "^8.18.0" }, "devDependencies": { "vite": "^5.4.0" } }

注意"type": "module"这一项,它允许我们使用 ES Module 语法。三年前许多 Node 教程还在写 CommonJS,但 2026 年新项目直接使用 ESM 是更主流的选择。

3.2 服务端:WebSocket 网关与状态管理

服务端的核心职责有三个:维护在线玩家列表、接收玩家位置更新、广播给其他客户端。我们先来看一份基础实现。

// 文件路径:server/index.js import express from 'express'; import http from 'http'; import { WebSocketServer } from 'ws'; import crypto from 'crypto'; const app = express(); const port = 8081; // 使用 express 托管前端静态文件,方便生产环境直接运行 app.use(express.static('public')); const server = http.createServer(app); const wss = new WebSocketServer({ server }); // 玩家状态保存在内存中,原型阶段不引入数据库 const players = new Map(); function generateId() { return crypto.randomBytes(6).toString('hex'); } function broadcast(data) { const message = JSON.stringify(data); wss.clients.forEach((client) => { if (client.readyState === client.OPEN) { client.send(message); } }); } wss.on('connection', (ws) => { const playerId = generateId(); // 初始化新玩家,出生点固定在世界的某个安全位置 const initialState = { id: playerId, x: 0, y: 20, z: 0, rotY: 0, }; players.set(playerId, initialState); // 告诉新玩家自己的 ID 和当前世界的所有玩家信息 ws.send(JSON.stringify({ type: 'init', id: playerId, players: Array.from(players.entries()).map(([id, data]) => ({ id, ...data, })), })); // 广播新玩家加入事件 broadcast({ type: 'join', ...initialState, }); ws.on('message', (raw) => { let data; try { data = JSON.parse(raw.toString()); } catch (error) { console.error('Invalid JSON from client:', raw.toString()); return; } if (data.type === 'move') { const player = players.get(playerId); if (!player) return; // 简单校验坐标是否在合理范围内,防止非法消息污染世界状态 if (!Number.isFinite(data.x) || !Number.isFinite(data.y) || !Number.isFinite(data.z)) { return; } player.x = data.x; player.y = data.y; player.z = data.z; player.rotY = data.rotY || 0; broadcast({ type: 'move', id: playerId, x: player.x, y: player.y, z: player.z, rotY: player.rotY, }); } if (data.type === 'chat') { const content = String(data.message || '').slice(0, 100); broadcast({ type: 'chat', id: playerId, message: content, }); } }); ws.on('close', () => { players.delete(playerId); broadcast({ type: 'leave', id: playerId, }); }); }); server.listen(port, () => { console.log(`Aethercraft server is running at http://localhost:${port}`); });

这段代码中的几个关键点值得展开说明。

players是一个 Map,以 playerId 为键,保存每个玩家的实时位置。它起到“世界内存状态”的作用。如果服务端重启,所有玩家的位置都会丢失,这对于原型来说是能接受的。

broadcast函数负责给所有在线客户端发送消息。要注意wss.clients包含所有连接中的客户端,不区分是否已经初始化完成。因此加入一个新的玩家时,要先给他发送init消息,再向其他人广播join,这个顺序不能颠倒。

收到的坐标数据需要做一次Number.isFinite校验。这是一道非常基础但很重要的防线,防止某个异常客户端发送NaN或 Infinity,导致其他玩家看到角色瞬间飞到无穷远。

chat 消息做了长度裁剪,最多接受 100 个字符。网络环境下不能信任任何输入,哪怕是自己写的客户端,也要有防御习惯。

3.3 客户端:Three.js 场景初始化

浏览器端是项目的重头戏。我们用 Vite 管理前端资源,所以 public 目录下会有一个 index.html 和一个 main.js 模块。

先看 index.html:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Aethercraft - Open World Prototype</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #0b0e14; font-family: 'Segoe UI', system-ui, sans-serif; } #hud { position: fixed; top: 14px; left: 14px; color: #fff; background: rgba(0, 0, 0, 0.5); padding: 8px 14px; border-radius: 8px; font-size: 14px; z-index: 20; pointer-events: none; } #stats { position: fixed; top: 14px; right: 14px; color: #8be9fd; background: rgba(0, 0, 0, 0.4); padding: 8px 14px; border-radius: 8px; font-size: 13px; z-index: 20; pointer-events: none; } #chat { position: fixed; bottom: 18px; left: 18px; width: 340px; z-index: 20; } #chat-log { max-height: 140px; overflow-y: auto; background: rgba(0, 0, 0, 0.45); border-radius: 8px; padding: 8px 12px; color: #eee; font-size: 13px; margin-bottom: 6px; } #chat-input { width: 100%; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 14px; } </style> </head> <body> <div id="hud">Aethercraft · 基础原型</div> <div id="stats">坐标: 0, 0, 0</div> <div id="chat"> <div id="chat-log"></div> <input id="chat-input" placeholder="按 Enter 发送消息" /> </div> <script type="module" src="./main.js"></script> </body> </html>

这里没有使用任何构建期模板,而是直接让 Vite 加载 index.html。#hud是顶部状态栏,#stats用于调试坐标,#chat是消息交互区。

3.4 客户端主逻辑:渲染、移动与联网

main.js 是客户端最核心的模块。这个文件把 Three.js 场景、玩家控制、WebSocket 网络通信整合在一起。由于代码量较大,我把它拆成几个模块,便于维护。

// 文件路径:public/main.js import * as THREE from 'three'; import { WebSocketClient } from './network.js'; import { setupPlayerControls } from './controls.js'; import { createWorld, createPlayerMesh } from './world.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87ceeb); const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(0, 25, 40); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; document.body.appendChild(renderer.domElement); // 基础光照 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(50, 80, 30); scene.add(dirLight); // 创建测试世界:一块草地 + 几个方块 const ground = createWorld(); scene.add(ground); // 本地玩家数据 const localPlayer = { mesh: createPlayerMesh(0xff5555), position: new THREE.Vector3(0, 1, 0), }; scene.add(localPlayer.mesh); // 远程玩家映射,用 id 对应 mesh const remotePlayers = new Map(); // 初始化网络模块 const net = new WebSocketClient({ onInit: ({ id, players }) => { localPlayer.id = id; players.forEach((p) => { if (p.id === id) return; addRemotePlayer(p); }); }, onJoin: (player) => { if (player.id === localPlayer.id) return; addRemotePlayer(player); }, onMove: (player) => { const remote = remotePlayers.get(player.id); if (remote) { remote.targetPosition.set(player.x, player.y, player.z); remote.mesh.position.copy(remote.targetPosition); } }, onLeave: (id) => { removeRemotePlayer(id); }, onChat: ({ id, message }) => { appendChat(`${id.slice(0, 4)}: ${message}`); }, }); net.connect(); function addRemotePlayer(player) { const mesh = createPlayerMesh(0x55aaff); mesh.position.set(player.x, player.y, player.z); scene.add(mesh); remotePlayers.set(player.id, { mesh, targetPosition: new THREE.Vector3(player.x, player.y, player.z), }); } function removeRemotePlayer(id) { const remote = remotePlayers.get(id); if (remote) { scene.remove(remote.mesh); remotePlayers.delete(id); } } function appendChat(message) { const log = document.getElementById('chat-log'); const line = document.createElement('div'); line.textContent = message; log.appendChild(line); log.scrollTop = log.scrollHeight; } // 玩家控制模块:返回移动速度和朝向,供动画循环使用 const controls = setupPlayerControls(camera, localPlayer.mesh); const chatInput = document.getElementById('chat-input'); chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const value = chatInput.value.trim(); if (value) { net.sendChat(value); chatInput.value = ''; } } }); // 主循环 function animate() { requestAnimationFrame(animate); const delta = controls.update(); localPlayer.mesh.position.copy(controls.position); localPlayer.mesh.rotation.y = controls.rotationY; // 更新 HUD 中的坐标显示 const stats = document.getElementById('stats'); const pos = localPlayer.mesh.position; stats.textContent = `坐标: ${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)}`; // 上传本地玩家位置变化 net.sendMove({ x: pos.x, y: pos.y, z: pos.z, rotY: controls.rotationY, }); // 简单跟随移动:远程玩家直接靠向目标位置 remotePlayers.forEach((remote) => { remote.mesh.position.lerp(remote.targetPosition, 0.1); }); renderer.render(scene, camera); } // 窗口尺寸变化时,重新设置相机纵横比 window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); animate();

这段代码里,Three.js 的职责非常单一:创建场景、渲染对象、驱动动画循环。网络模块被隔离在network.js中,玩家控制逻辑在controls.js中,创建世界和玩家模型的函数在world.js中。模块化虽然多写了几个文件,但对后续扩展至关重要。

remotePlayers中每个远程玩家都保存了一个targetPosition,主循环每帧把网格向目标位置做插值移动,而不是直接瞬间跳跃到目标坐标。这是一种非常轻量的平滑处理,在网络不稳定时能有效减少画面抖动。

3.5 网络模块封装

继续看 network.js 的实现:

// 文件路径:public/network.js export class WebSocketClient { constructor(handlers) { this.handlers = handlers; this.ws = null; } connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${protocol}://${location.host}`; this.ws = new WebSocket(url); this.ws.addEventListener('open', () => { console.log('[Aethercraft] WebSocket connected'); }); this.ws.addEventListener('message', (event) => { let data; try { data = JSON.parse(event.data); } catch (err) { console.warn('收到无法解析的消息:', event.data); return; } const handler = this.handlers[data.type]; if (handler) { handler(data); } }); this.ws.addEventListener('close', () => { console.warn('[Aethercraft] 连接已断开'); }); this.ws.addEventListener('error', (error) => { console.error('[Aethercraft] WebSocket error', error); }); } sendMove(position) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ type: 'move', ...position, })); } sendChat(message) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ type: 'chat', message, })); } }

这里的 WebSocket 地址动态拼接自location.host,好处是在本地开发和生产部署时不需要修改代码。如果是 https 页面就走 wss,否则走 ws。这一点经常会被忽视,等部署到服务器上才发现浏览器拦截了明文 WebSocket 请求。

3.6 玩家控制与移动实现

玩家控制模块是实现“在开放世界里走路”的基础。这里采用第三人称跟随相机的简化方案:相机始终在玩家角色后方,WASD 控制移动方向。

// 文件路径:public/controls.js import * as THREE from 'three'; export function setupPlayerControls(camera, mesh) { const keys = {}; const position = mesh.position.clone(); let rotationY = 0; const cameraOffset = new THREE.Vector3(0, 8, 18); document.addEventListener('keydown', (e) => { keys[e.code] = true; }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); function update() { const speed = 12; const move = new THREE.Vector3(); if (keys['KeyW']) move.z -= 1; if (keys['KeyS']) move.z += 1; if (keys['KeyA']) move.x -= 1; if (keys['KeyD']) move.x += 1; if (move.lengthSq() > 0) { move.normalize().multiplyScalar(speed * (1 / 60)); position.add(move); // 根据移动方向更新角色朝向 rotationY = Math.atan2(move.x, move.z); } // 保持角色始终在某个高度以上(平地测试阶段) position.y = 1; mesh.position.copy(position); mesh.rotation.y = rotationY; // 相机跟随:以角色为中心,保持固定偏移 camera.position.copy(position).add(cameraOffset); camera.lookAt(position); return { position, rotationY, }; } return { update }; }

这段代码里移动速度乘以1/60,是为了让移动速度不依赖帧率。虽然 requestAnimationFrame 在大多数显示器上都是 60Hz,但锁帧率并不是严格的跨设备方案。原型阶段用固定步长也够用,真正的生产项目建议使用clock.getDelta()计算真实时间差。

相机跟随方案是简单的“角色背后视角”,没有引入 OrbitControls,因为多人联机时如果每个客户端都能自由旋转世界视角,很容易头晕,而且还需要额外同步视角状态。

3.7 世界与玩家模型

world.js 负责生成测试场景和角色模型。为了让联机效果肉眼可见,两个玩家的颜色被设置为一红一蓝。

// 文件路径:public/world.js import * as THREE from 'three'; export function createWorld() { const group = new THREE.Group(); // 草地 const ground = new THREE.Mesh( new THREE.PlaneGeometry(200, 200), new THREE.MeshStandardMaterial({ color: 0x6aab4a }) ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; group.add(ground); // 测试方块,模拟世界的简单障碍物 const boxGeo = new THREE.BoxGeometry(2, 2, 2); const boxMat = new THREE.MeshStandardMaterial({ color: 0x8b6f47 }); const positions = [ [8, 1, 5], [-10, 1, 12], [15, 1, -8], [-5, 1, -15], [0, 1, -25], ]; positions.forEach(([x, y, z]) => { const box = new THREE.Mesh(boxGeo, boxMat); box.position.set(x, y, z); group.add(box); }); return group; } export function createPlayerMesh(color) { const group = new THREE.Group(); // 身体 const body = new THREE.Mesh( new THREE.BoxGeometry(1, 2, 1), new THREE.MeshStandardMaterial({ color }) ); body.position.y = 1; group.add(body); // 头 const head = new THREE.Mesh( new THREE.BoxGeometry(0.7, 0.7, 0.7), new THREE.MeshStandardMaterial({ color: 0xffdbb5 }) ); head.position.y = 2.4; group.add(head); // 小旗子,方便观察朝向 const flag = new THREE.Mesh( new THREE.BoxGeometry(0.1, 1.2, 0.1), new THREE.MeshStandardMaterial({ color: 0xffdd00 }) ); flag.position.set(0, 2.9, 0.8); group.add(flag); return group; }

两个玩家模型都使用简单的几何体组合,一个是红色方块人,一个是蓝色方块人。后续如果引入 glTF 模型,只需要替换createPlayerMesh的返回值即可。

4. 运行测试:浏览器直接访问与联机验证

4.1 启动服务端

先启动 Node 服务端,让它监听 8081 端口:

node server/index.js

如果一切正常,终端会输出:

Aethercraft server is running at http://localhost:8081

注意,这里 Node 服务同时托管了 public 目录的静态文件,所以不需要额外启动 Vite 开发服务器。这也是为什么我们只需要一个端口就能同时完成静态资源访问和 WebSocket 通信。

4.2 打开浏览器验证

访问http://localhost:8081,你应该看到:

  • 蓝天下有一片草地。
  • 草地上分布着几个棕色方块。
  • 屏幕左上角显示“Aethercraft 基础原型”。
  • 右上角显示当前角色坐标。
  • 一个红色方块人站在世界中央。

通过 WASD 移动角色,右上角坐标会同步变化。相机始终从斜后方跟随。

4.3 双开浏览器模拟联机

接着打开两个浏览器窗口,分别访问同一个地址。第一个窗口里是红色方块人,第二个窗口里是蓝色方块人。

在一个窗口中按 W 前进,另一个窗口会看到红色方块人向前移动。这种“看到别人在动”的效果,说明服务端的位置广播已经全链路打通。

4.4 聊天功能验证

在任意窗口底部输入文字并回车,另一个窗口的聊天区域会立即显示消息。消息格式带有发送者 ID 的前四位,方便区分是谁发的。

4.5 网络延迟观察

如果两个窗口在同一台电脑上,网络延迟基本为零,移动看起来是即时的。如果分别放在两台设备上,通过局域网访问,依然可以感受到相对流畅的同步。由于原型没有做预测和回滚,网络抖动明显时可能会出现轻微的“拉扯感”,这属于正常现象。

5. 常见问题与排查思路

问题现象常见原因解决思路
页面打开但看不到方块和角色Three.js 资源加载失败或 JS 模块报错打开浏览器控制台,查看 Error 信息,确认 vite 与 three 版本是否匹配
WebSocket 连接失败端口被占用或协议不匹配检查服务端端口,确认页面是 http 访问且没有混入 https
双开窗口但看不到另一个玩家网络初始化顺序错误检查 init 消息是否在 join 广播之前发送,检查远程玩家对象是否正确添加
角色移动卡顿、瞬移网络同步频繁抖动检查发送频率,考虑在服务端限制消息速率,客户端增加位置插值
聊天消息丢失消息被 JSON 解析失败检查发送端是否正确 JSON.stringify,检查消息长度是否超过限制
服务端启动失败提示端口占用8081 已被其他进程占用使用lsof -i:8081查找占用进程,或修改项目端口号

另外,如果你在局域网中从另一台设备访问,需要确保防火墙允许 Node.js 进程通过 8081 端口。这个问题在笔记本上很常见,因为 Windows 和 macOS 默认会拦截未授权的入站连接。

6. AI 辅助编码的经验总结

6.1 一个可复用的 AI 工作流

经过这个项目,我沉淀了一套适用于小游戏原型的 AI 辅助开发工作流:

  • 分解任务:每个功能点拆成一个小模块,比如“创建 Three.js 场景”“实现 WebSocket 客户端”“写一个玩家移动控制”。
  • 精确提问:向 AI 描述清楚输入和输出,比如“用 Three.js 创建一个第三人称跟随相机,角色用 Box 组成,返回一个 update 函数”。
  • 运行验证:AI 给的代码必须放到真实项目里跑一遍,看控制台报错。
  • 迭代修复:把报错信息直接贴给 AI,让它解释原因并提供修复版。
  • 人工审查:网络通信、坐标同步、安全校验这些关键点不能交给 AI 全权负责。

6.2 哪些代码适合交给 AI

像场景灯光搭建、网格体创建、响应式窗口适配这类固定模式代码,AI 生成效率很高。它们结构清晰、很少依赖项目上下文,即使小有出入也容易改。

6.3 哪些代码必须人工把关

多人联机的核心逻辑不建议让 AI 直接生成。因为 AI 不理解你的同步策略,也不知道你的服务端何时应该广播、何时应该静默。尤其是init和join的顺序、玩家离开时的清理逻辑,一旦写错,联机效果就是一团乱麻。

7. 最佳实践与工程建议

7.1 网络同步设计原则

当前项目采用最朴素的全量位置广播,每个客户端每帧都上传一次坐标,服务端再广播给所有人。这条链路简单,但带宽消耗不小。生产环境下建议做三件事:

  • 降低发送频率:每秒发送 10 到 20 次位置更新,而不是每帧都发。
  • 合并数据包:把短时间内多次位置变化合并成一条消息。
  • 做状态插值:客户端不要直接跳到目标点,而是采用加权插值或平滑滤波。

这些优化不会改变通信协议本身,但能显著改善多人同时在线的稳定性。

7.2 关于安全边界

WebSocket 暴露在公网时,必须考虑认证和滥用问题。那些不做任何鉴权、随意接收任意来源消息的服务端,很容易被刷爆带宽或者被恶意脚本写入垃圾坐标。建议根据实际项目规模加上 token 校验、消息频率限制、数据长度限制,以及关键操作的身份验证。

7.3 代码工程化建议

即使是一个完成度 10% 的原型,也可以从第一天就坚持模块化。world.js、controls.js、network.js的划分,让每个文件只做一件事。变量命名也要直白,比如remotePlayers明确表示“远端玩家的集合”,不要写出data1、data2这类无法读懂的命名。

7.4 性能优化初探

在低端笔记本上测试,当前原型可以稳定运行。主要性能瓶颈会出现在两个方向:模型面数和网络消息量。后续如果计划加入大量地形区块,建议把 chunk 单独建模,只渲染视野范围内的区块,并使用合并网格减少 draw call。

8. 下一步规划

aethercraft 现阶段的地基已经可以跑通,接下来最值得做的几件事是:

  • 加入无限地形生成,用区块加载的方式让世界前后左右不断延伸。
  • 加入方块放置与破坏,把世界真正改造为可编辑的沙盒空间。
  • 引入碰撞检测,让玩家不再穿透地形和方块。
  • 实现服务端状态持久化,玩家离开再回来时位置不会丢。
  • 优化网络协议,从全量广播改为范围广播,减少无关玩家的消息干扰。

如果你也想尝试用 AI 搭一个浏览器联机原型,可以从这篇文章里的代码开始改。先用两个浏览器窗口跑通,再慢慢替换模型、扩展玩法。等哪天你的朋友从外面输入网址就能跳进你的世界,那时候你就能真正感受到这 10% 的完成度有多重要了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询