1361 lines
46 KiB
HTML
1361 lines
46 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>多人3D交互场景 - 空气墙系统</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
#loading-screen {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: #ffffff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
transition: opacity 0.5s ease;
|
|
}
|
|
#loading-screen.hidden { opacity: 0; pointer-events: none; }
|
|
.loader {
|
|
width: 60px;
|
|
height: 60px;
|
|
border: 4px solid rgba(0,0,0,0.1);
|
|
border-top-color: #333333;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.loading-text {
|
|
color: #333333;
|
|
font-size: 18px;
|
|
margin-top: 20px;
|
|
letter-spacing: 2px;
|
|
}
|
|
#canvas-container { width: 100%; height: 100%; }
|
|
body.camera-rotate { cursor: grabbing !important; }
|
|
body.camera-ready { cursor: default; }
|
|
|
|
#chat-input-container {
|
|
position: fixed;
|
|
bottom: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: none;
|
|
z-index: 200;
|
|
}
|
|
#chat-input-container.active {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
#chat-input {
|
|
width: 300px;
|
|
padding: 12px 16px;
|
|
border: 2px solid rgba(0,0,0,0.3);
|
|
border-radius: 25px;
|
|
background: rgba(255,255,255,0.95);
|
|
color: #333;
|
|
font-size: 14px;
|
|
outline: none;
|
|
}
|
|
#chat-input:focus {
|
|
border-color: rgba(0,0,0,0.5);
|
|
}
|
|
#chat-input::placeholder {
|
|
color: rgba(0,0,0,0.4);
|
|
}
|
|
#chat-hint {
|
|
color: #333;
|
|
font-size: 12px;
|
|
background: rgba(255,255,255,0.9);
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="loading-screen">
|
|
<div class="loader"></div>
|
|
<div class="loading-text">正在进入场景</div>
|
|
</div>
|
|
<div id="canvas-container"></div>
|
|
|
|
<div id="chat-input-container">
|
|
<input type="text" id="chat-input" placeholder="输入消息..." maxlength="100" />
|
|
<span id="chat-hint">回车发送 | ESC取消</span>
|
|
</div>
|
|
|
|
<script src="https://cdn.pubnub.com/sdk/javascript/pubnub.4.37.0.min.js"></script>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
|
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
|
}
|
|
}
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
|
|
// ============ 全局配置 ============
|
|
const CONFIG = {
|
|
HEARTBEAT_INTERVAL: 100,
|
|
POSITION_LERP_SPEED: 8,
|
|
WALK_SPEED: 5,
|
|
RUN_SPEED: 10,
|
|
ROTATION_SPEED: 12,
|
|
JUMP_FORCE: 7,
|
|
GRAVITY: 18,
|
|
CAMERA_MIN_DISTANCE: 4,
|
|
CAMERA_MAX_DISTANCE: 18,
|
|
DUST_PARTICLE_COUNT: 50,
|
|
SHADOW_QUALITY: 2048,
|
|
DOUBLE_CLICK_TIMEOUT: 250,
|
|
GROUND_SIZE: 100000,
|
|
FAR_PLANE: 2000,
|
|
FOG: { ENABLED: true, NEAR: 50, FAR: 150, COLOR: 0xffffff },
|
|
CHARACTER: { GROUND_OFFSET: 0.001, SHADOW_BIAS: -0.0001, FOOT_OFFSET: 0.005 },
|
|
CAMERA: {
|
|
YAW_SENSITIVITY: 0.006,
|
|
PITCH_SENSITIVITY: 0.004,
|
|
ZOOM_SENSITIVITY: 0.08,
|
|
ZOOM_MIN_SPEED: 0.3,
|
|
ZOOM_MAX_SPEED: 2.5,
|
|
SMOOTHING: 0.15,
|
|
MIN_PITCH: 0.05,
|
|
MAX_PITCH: Math.PI / 2 - 0.1,
|
|
DEFAULT_YAW: 0,
|
|
DEFAULT_PITCH: 0.35,
|
|
DEFAULT_DISTANCE: 8
|
|
},
|
|
CHAT: {
|
|
DISPLAY_DURATION: 5000,
|
|
CANVAS_WIDTH: 320,
|
|
CANVAS_HEIGHT: 80,
|
|
SPRITE_WIDTH: 1.2,
|
|
SPRITE_HEIGHT: 0.3
|
|
},
|
|
AIR_WALL: {
|
|
ENABLED: true,
|
|
BOUNDS: { minX: -35, maxX: 35, minZ: -35, maxZ: 35 },
|
|
BOUNCE_FORCE: 15,
|
|
BOUNCE_DAMPING: 0.8,
|
|
COLLISION_BUFFER: 0.5,
|
|
RIPPLE: { ENABLED: true, MAX_COUNT: 20, DURATION: 1.0, MAX_SCALE: 2.5, COLOR: 0xffffff }
|
|
}
|
|
};
|
|
|
|
// ============ PUBNUB 配置 ============
|
|
let pubnub = null;
|
|
let multiplayerEnabled = false;
|
|
const myId = 'user_' + Math.random().toString(36).substr(2, 9);
|
|
const roomId = 'public';
|
|
|
|
try {
|
|
pubnub = new PubNub({
|
|
publishKey: 'pub-c-6d8d8c8c-d8c8-d8c8-d8c8-d8c8d8c8d8c8',
|
|
subscribeKey: 'sub-c-6d8d8c8c-d8c8-d8c8-d8c8-d8c8d8c8d8c8',
|
|
uuid: myId
|
|
});
|
|
multiplayerEnabled = true;
|
|
} catch (e) {
|
|
console.log('多人模式不可用:', e);
|
|
}
|
|
|
|
// ============ 场景初始化 ============
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0xffffff);
|
|
if (CONFIG.FOG.ENABLED) {
|
|
scene.fog = new THREE.Fog(CONFIG.FOG.COLOR, CONFIG.FOG.NEAR, CONFIG.FOG.FAR);
|
|
}
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, CONFIG.FAR_PLANE);
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
antialias: true,
|
|
powerPreference: "high-performance",
|
|
logarithmicDepthBuffer: true
|
|
});
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.2;
|
|
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
|
|
|
// ============ 光照 ============
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
|
|
const sunLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
|
sunLight.position.set(30, 50, 30);
|
|
sunLight.castShadow = true;
|
|
sunLight.shadow.mapSize.width = CONFIG.SHADOW_QUALITY;
|
|
sunLight.shadow.mapSize.height = CONFIG.SHADOW_QUALITY;
|
|
sunLight.shadow.camera.near = 0.5;
|
|
sunLight.shadow.camera.far = 150;
|
|
sunLight.shadow.camera.left = -60;
|
|
sunLight.shadow.camera.right = 60;
|
|
sunLight.shadow.camera.top = 60;
|
|
sunLight.shadow.camera.bottom = -60;
|
|
sunLight.shadow.bias = CONFIG.CHARACTER.SHADOW_BIAS;
|
|
sunLight.shadow.normalBias = 0.02;
|
|
scene.add(sunLight);
|
|
scene.add(sunLight.target);
|
|
|
|
const backLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
|
backLight.position.set(-20, 15, -20);
|
|
scene.add(backLight);
|
|
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0xffffff, 0.4));
|
|
|
|
// ============ 地面 ============
|
|
const groundGeometry = new THREE.PlaneGeometry(CONFIG.GROUND_SIZE, CONFIG.GROUND_SIZE);
|
|
const groundMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xffffff,
|
|
roughness: 0.85,
|
|
metalness: 0.05
|
|
});
|
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
ground.receiveShadow = true;
|
|
scene.add(ground);
|
|
|
|
// ============ 空气墙系统 ============
|
|
const airWallSystem = {
|
|
ripples: [],
|
|
|
|
checkCollision(pos) {
|
|
const bounds = CONFIG.AIR_WALL.BOUNDS;
|
|
const buffer = CONFIG.AIR_WALL.COLLISION_BUFFER;
|
|
const result = { collided: false, penetration: 0, normal: new THREE.Vector3(), point: pos.clone() };
|
|
|
|
if (pos.x < bounds.minX + buffer) {
|
|
result.collided = true;
|
|
result.penetration = (bounds.minX + buffer) - pos.x;
|
|
result.normal.set(1, 0, 0);
|
|
result.point.x = bounds.minX;
|
|
} else if (pos.x > bounds.maxX - buffer) {
|
|
result.collided = true;
|
|
result.penetration = pos.x - (bounds.maxX - buffer);
|
|
result.normal.set(-1, 0, 0);
|
|
result.point.x = bounds.maxX;
|
|
}
|
|
|
|
if (pos.z < bounds.minZ + buffer) {
|
|
result.collided = true;
|
|
result.penetration = (bounds.minZ + buffer) - pos.z;
|
|
result.normal.set(0, 0, 1);
|
|
result.point.z = bounds.minZ;
|
|
} else if (pos.z > bounds.maxZ - buffer) {
|
|
result.collided = true;
|
|
result.penetration = pos.z - (bounds.maxZ - buffer);
|
|
result.normal.set(0, 0, -1);
|
|
result.point.z = bounds.maxZ;
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
handleBounce(char, collision) {
|
|
if (!collision || !collision.collided) return;
|
|
|
|
const bounceVelocity = collision.normal.clone().multiplyScalar(
|
|
CONFIG.AIR_WALL.BOUNCE_FORCE * CONFIG.AIR_WALL.BOUNCE_DAMPING
|
|
);
|
|
char.velocity.x += bounceVelocity.x;
|
|
char.velocity.z += bounceVelocity.z;
|
|
char.position.add(collision.normal.clone().multiplyScalar(collision.penetration * 3));
|
|
this.createRipple(collision.point);
|
|
},
|
|
|
|
createRipple(pos) {
|
|
if (!CONFIG.AIR_WALL.RIPPLE.ENABLED) return;
|
|
|
|
if (this.ripples.length >= CONFIG.AIR_WALL.RIPPLE.MAX_COUNT) {
|
|
const old = this.ripples.shift();
|
|
scene.remove(old.mesh);
|
|
old.mesh.geometry.dispose();
|
|
old.mesh.material.dispose();
|
|
}
|
|
|
|
const geometry = new THREE.RingGeometry(0.1, 0.2, 32);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
color: CONFIG.AIR_WALL.RIPPLE.COLOR,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
mesh.position.set(pos.x, 0.05, pos.z);
|
|
mesh.rotation.x = -Math.PI / 2;
|
|
scene.add(mesh);
|
|
|
|
this.ripples.push({
|
|
mesh: mesh,
|
|
life: 0,
|
|
duration: CONFIG.AIR_WALL.RIPPLE.DURATION,
|
|
maxScale: CONFIG.AIR_WALL.RIPPLE.MAX_SCALE
|
|
});
|
|
},
|
|
|
|
update(delta) {
|
|
this.ripples = this.ripples.filter(r => {
|
|
r.life += delta;
|
|
const progress = r.life / r.duration;
|
|
|
|
if (progress >= 1) {
|
|
scene.remove(r.mesh);
|
|
r.mesh.geometry.dispose();
|
|
r.mesh.material.dispose();
|
|
return false;
|
|
}
|
|
|
|
const scale = 1 + (r.maxScale - 1) * progress;
|
|
r.mesh.scale.setScalar(scale);
|
|
r.mesh.material.opacity = 0.8 * (1 - progress);
|
|
return true;
|
|
});
|
|
},
|
|
|
|
updateChar(char, delta) {
|
|
if (!CONFIG.AIR_WALL.ENABLED || char.isJumping) return;
|
|
|
|
const collision = this.checkCollision(char.position);
|
|
if (collision && collision.collided) {
|
|
this.handleBounce(char, collision);
|
|
}
|
|
|
|
if (char.velocity) {
|
|
char.velocity.multiplyScalar(0.95);
|
|
char.position.x += char.velocity.x * delta;
|
|
char.position.z += char.velocity.z * delta;
|
|
|
|
if (char.velocity.length() < 0.01) {
|
|
char.velocity.set(0, 0, 0);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// ============ 粒子 ============
|
|
const dustParticles = [];
|
|
const dustGeo = new THREE.SphereGeometry(0.06, 6, 6);
|
|
const dustMat = new THREE.MeshBasicMaterial({ color: 0xdddddd, transparent: true, opacity: 0.5 });
|
|
|
|
function createDustParticle(pos) {
|
|
let particle = dustParticles.find(p => !p.active);
|
|
|
|
if (!particle && dustParticles.length < CONFIG.DUST_PARTICLE_COUNT) {
|
|
const mesh = new THREE.Mesh(dustGeo, dustMat);
|
|
scene.add(mesh);
|
|
particle = { mesh: mesh, active: false };
|
|
dustParticles.push(particle);
|
|
}
|
|
|
|
if (particle) {
|
|
particle.active = true;
|
|
particle.mesh.position.set(pos.x, 0.02, pos.z);
|
|
particle.mesh.scale.setScalar(1);
|
|
particle.mesh.material.opacity = 0.5;
|
|
particle.life = 1.0;
|
|
particle.velocity = new THREE.Vector3(
|
|
(Math.random() - 0.5) * 0.08,
|
|
Math.random() * 0.08,
|
|
(Math.random() - 0.5) * 0.08
|
|
);
|
|
}
|
|
}
|
|
|
|
function updateDust(delta) {
|
|
dustParticles.forEach(p => {
|
|
if (p.active) {
|
|
p.life -= delta * 2.5;
|
|
p.mesh.position.add(p.velocity);
|
|
p.mesh.position.y += delta * 0.4;
|
|
p.mesh.scale.multiplyScalar(1 + delta * 0.8);
|
|
p.mesh.material.opacity = p.life * 0.5;
|
|
|
|
if (p.life <= 0) {
|
|
p.active = false;
|
|
p.mesh.position.y = -100;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============ 指示器 ============
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
const mouseWorldPos = new THREE.Vector3();
|
|
|
|
const walkRingGeo = new THREE.RingGeometry(0.3, 0.4, 32);
|
|
const walkRingMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8, side: THREE.DoubleSide });
|
|
const walkRing = new THREE.Mesh(walkRingGeo, walkRingMat);
|
|
walkRing.rotation.x = -Math.PI / 2;
|
|
walkRing.position.y = 0.01;
|
|
walkRing.visible = false;
|
|
scene.add(walkRing);
|
|
|
|
const runRingGeo = new THREE.RingGeometry(0.35, 0.5, 32);
|
|
const runRingMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
|
|
const runRing = new THREE.Mesh(runRingGeo, runRingMat);
|
|
runRing.rotation.x = -Math.PI / 2;
|
|
runRing.position.y = 0.01;
|
|
runRing.visible = false;
|
|
scene.add(runRing);
|
|
|
|
// ============ 角色材质 ============
|
|
const charMat = new THREE.MeshPhysicalMaterial({
|
|
color: 0xffffff,
|
|
metalness: 0.05,
|
|
roughness: 0.15,
|
|
transmission: 0.5,
|
|
thickness: 0.3,
|
|
clearcoat: 0.8,
|
|
clearcoatRoughness: 0.2,
|
|
emissive: 0xffffff,
|
|
emissiveIntensity: 0.3
|
|
});
|
|
|
|
// ============ 相机控制器 ============
|
|
const cameraController = {
|
|
yaw: CONFIG.CAMERA.DEFAULT_YAW,
|
|
pitch: CONFIG.CAMERA.DEFAULT_PITCH,
|
|
distance: CONFIG.CAMERA.DEFAULT_DISTANCE,
|
|
targetYaw: CONFIG.CAMERA.DEFAULT_YAW,
|
|
targetPitch: CONFIG.CAMERA.DEFAULT_PITCH,
|
|
targetDistance: CONFIG.CAMERA.DEFAULT_DISTANCE,
|
|
isDragging: false,
|
|
lastMouseX: 0,
|
|
lastMouseY: 0,
|
|
camPos: new THREE.Vector3(0, 8, 12),
|
|
|
|
startDrag(x, y) {
|
|
this.isDragging = true;
|
|
this.lastMouseX = x;
|
|
this.lastMouseY = y;
|
|
document.body.classList.add('camera-rotate');
|
|
document.body.classList.remove('camera-ready');
|
|
},
|
|
|
|
updateDrag(x, y) {
|
|
if (!this.isDragging) return;
|
|
this.targetYaw -= (x - this.lastMouseX) * CONFIG.CAMERA.YAW_SENSITIVITY;
|
|
this.targetPitch += (y - this.lastMouseY) * CONFIG.CAMERA.PITCH_SENSITIVITY;
|
|
this.targetPitch = Math.max(CONFIG.CAMERA.MIN_PITCH, Math.min(CONFIG.CAMERA.MAX_PITCH, this.targetPitch));
|
|
this.lastMouseX = x;
|
|
this.lastMouseY = y;
|
|
},
|
|
|
|
endDrag() {
|
|
this.isDragging = false;
|
|
document.body.classList.remove('camera-rotate');
|
|
document.body.classList.add('camera-ready');
|
|
},
|
|
|
|
zoom(dy, mode) {
|
|
let delta = mode === 1 ? dy * 40 : dy;
|
|
const speed = Math.max(CONFIG.CAMERA.ZOOM_MIN_SPEED,
|
|
Math.min(CONFIG.CAMERA.ZOOM_MAX_SPEED, this.distance * CONFIG.CAMERA.ZOOM_SENSITIVITY));
|
|
this.targetDistance = Math.max(CONFIG.CAMERA_MIN_DISTANCE,
|
|
Math.min(CONFIG.CAMERA_MAX_DISTANCE, this.targetDistance + delta * speed * 0.01));
|
|
},
|
|
|
|
update(delta) {
|
|
const lerp = 1 - Math.exp(-CONFIG.CAMERA.SMOOTHING * 60 * delta);
|
|
|
|
let yawDiff = this.targetYaw - this.yaw;
|
|
while (yawDiff > Math.PI) yawDiff -= Math.PI * 2;
|
|
while (yawDiff < -Math.PI) yawDiff += Math.PI * 2;
|
|
this.yaw += yawDiff * lerp;
|
|
|
|
this.pitch += (this.targetPitch - this.pitch) * lerp;
|
|
this.distance += (this.targetDistance - this.distance) * lerp;
|
|
|
|
while (this.yaw < 0) this.yaw += Math.PI * 2;
|
|
while (this.yaw >= Math.PI * 2) this.yaw -= Math.PI * 2;
|
|
},
|
|
|
|
calcPos(target) {
|
|
const x = Math.sin(this.yaw) * Math.cos(this.pitch) * this.distance;
|
|
const y = Math.sin(this.pitch) * this.distance + 1.5;
|
|
const z = Math.cos(this.yaw) * Math.cos(this.pitch) * this.distance;
|
|
return new THREE.Vector3(target.x + x, target.y + y, target.z + z);
|
|
}
|
|
};
|
|
|
|
// ============ 键盘输入 ============
|
|
const keys = { w: false, a: false, s: false, d: false, shift: false };
|
|
|
|
// ============ 聊天系统 ============
|
|
const chatSystem = {
|
|
isActive: false,
|
|
inputContainer: null,
|
|
input: null,
|
|
|
|
init() {
|
|
this.inputContainer = document.getElementById('chat-input-container');
|
|
this.input = document.getElementById('chat-input');
|
|
},
|
|
|
|
show() {
|
|
this.isActive = true;
|
|
this.inputContainer.classList.add('active');
|
|
this.input.value = '';
|
|
this.input.focus();
|
|
},
|
|
|
|
hide() {
|
|
this.isActive = false;
|
|
this.inputContainer.classList.remove('active');
|
|
this.input.value = '';
|
|
this.input.blur();
|
|
},
|
|
|
|
send() {
|
|
const message = this.input.value.trim();
|
|
if (message && localPlayer) {
|
|
localPlayer.showChatBubble(message);
|
|
|
|
if (multiplayerEnabled && pubnub) {
|
|
pubnub.publish({
|
|
channel: roomId,
|
|
message: { type: 'chat', id: myId, text: message }
|
|
});
|
|
}
|
|
}
|
|
this.hide();
|
|
}
|
|
};
|
|
|
|
// ============ 角色类 ============
|
|
class Character {
|
|
constructor(id, isLocal) {
|
|
this.id = id;
|
|
this.isLocal = isLocal;
|
|
this.position = new THREE.Vector3(0, 0, 0);
|
|
this.velocity = new THREE.Vector3(0, 0, 0);
|
|
this.targetPosition = null;
|
|
this.rotation = new THREE.Euler(0, Math.random() * Math.PI * 2, 0);
|
|
this.isMoving = false;
|
|
this.isGreeting = false;
|
|
this.isRunning = false;
|
|
this.isJumping = false;
|
|
this.isBigPose = false;
|
|
this.jumpVelocity = 0;
|
|
this.walkCycle = 0;
|
|
this.greetingTime = 0;
|
|
this.idleTime = 0;
|
|
this.bigPoseTime = 0;
|
|
this.networkPosition = new THREE.Vector3();
|
|
this.networkRotationY = 0;
|
|
this.chatBubble = null;
|
|
this.chatTimeout = null;
|
|
|
|
this.mesh = this.createMesh();
|
|
scene.add(this.mesh);
|
|
}
|
|
|
|
createMesh() {
|
|
const group = new THREE.Group();
|
|
this.bodyContainer = new THREE.Group();
|
|
group.add(this.bodyContainer);
|
|
|
|
this.head = new THREE.Group();
|
|
this.head.position.set(0, 1.575, 0);
|
|
|
|
const headMesh = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.5, 32, 32),
|
|
charMat.clone()
|
|
);
|
|
this.head.add(headMesh);
|
|
|
|
const leftEar = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.18, 16, 16),
|
|
charMat.clone()
|
|
);
|
|
leftEar.position.set(-0.5, 0, 0);
|
|
this.head.add(leftEar);
|
|
|
|
const rightEar = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.18, 16, 16),
|
|
charMat.clone()
|
|
);
|
|
rightEar.position.set(0.5, 0, 0);
|
|
this.head.add(rightEar);
|
|
|
|
this.bodyContainer.add(this.head);
|
|
|
|
this.body = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.35, 0.5, 8, 16),
|
|
charMat.clone()
|
|
);
|
|
this.body.position.set(0, 0.875, 0);
|
|
this.body.castShadow = true;
|
|
this.bodyContainer.add(this.body);
|
|
|
|
this.leftArm = this.createArm(-0.45);
|
|
this.rightArm = this.createArm(0.45);
|
|
this.bodyContainer.add(this.leftArm, this.rightArm);
|
|
|
|
this.leftLeg = this.createLeg(-0.2);
|
|
this.rightLeg = this.createLeg(0.2);
|
|
this.bodyContainer.add(this.leftLeg, this.rightLeg);
|
|
|
|
group.position.set(0, CONFIG.CHARACTER.GROUND_OFFSET, 0);
|
|
return group;
|
|
}
|
|
|
|
createArm(x) {
|
|
const arm = new THREE.Group();
|
|
arm.position.set(x, 1.175, 0);
|
|
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.1, 0.4, 8, 16),
|
|
charMat.clone()
|
|
);
|
|
mesh.position.set(0, -0.25, 0);
|
|
mesh.castShadow = true;
|
|
arm.add(mesh);
|
|
|
|
const hand = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.12, 12, 12),
|
|
charMat.clone()
|
|
);
|
|
hand.position.set(0, -0.55, 0);
|
|
hand.castShadow = true;
|
|
arm.add(hand);
|
|
|
|
return arm;
|
|
}
|
|
|
|
createLeg(x) {
|
|
const leg = new THREE.Group();
|
|
leg.position.set(x, 0.525 + CONFIG.CHARACTER.FOOT_OFFSET, 0);
|
|
|
|
const mesh = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.12, 0.35, 8, 16),
|
|
charMat.clone()
|
|
);
|
|
mesh.position.set(0, -0.2, 0);
|
|
mesh.castShadow = true;
|
|
leg.add(mesh);
|
|
|
|
const foot = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.2, 0.15, 0.25),
|
|
charMat.clone()
|
|
);
|
|
foot.position.set(0, -0.45, 0.05);
|
|
foot.castShadow = true;
|
|
leg.add(foot);
|
|
|
|
return leg;
|
|
}
|
|
|
|
showChatBubble(text) {
|
|
if (this.chatBubble) {
|
|
this.mesh.remove(this.chatBubble.group);
|
|
}
|
|
if (this.chatTimeout) {
|
|
clearTimeout(this.chatTimeout);
|
|
}
|
|
|
|
const bubbleGroup = new THREE.Group();
|
|
bubbleGroup.position.set(0, 2.3, 0);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = CONFIG.CHAT.CANVAS_WIDTH;
|
|
canvas.height = CONFIG.CHAT.CANVAS_HEIGHT;
|
|
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
|
ctx.beginPath();
|
|
const r = 12;
|
|
ctx.moveTo(r, 0);
|
|
ctx.lineTo(canvas.width - r, 0);
|
|
ctx.quadraticCurveTo(canvas.width, 0, canvas.width, r);
|
|
ctx.lineTo(canvas.width, canvas.height - r);
|
|
ctx.quadraticCurveTo(canvas.width, canvas.height, canvas.width - r, canvas.height);
|
|
ctx.lineTo(r, canvas.height);
|
|
ctx.quadraticCurveTo(0, canvas.height, 0, canvas.height - r);
|
|
ctx.lineTo(0, r);
|
|
ctx.quadraticCurveTo(0, 0, r, 0);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#333333';
|
|
ctx.font = 'bold 24px Arial, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const maxWidth = canvas.width - 30;
|
|
let lines = [];
|
|
let currentLine = '';
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const testLine = currentLine + text[i];
|
|
const metrics = ctx.measureText(testLine);
|
|
if (metrics.width > maxWidth && currentLine.length > 0) {
|
|
lines.push(currentLine);
|
|
currentLine = text[i];
|
|
} else {
|
|
currentLine = testLine;
|
|
}
|
|
}
|
|
lines.push(currentLine);
|
|
lines = lines.slice(0, 2);
|
|
|
|
const lineHeight = 28;
|
|
const startY = canvas.height / 2 - (lines.length - 1) * lineHeight / 2;
|
|
lines.forEach((line, i) => {
|
|
ctx.fillText(line, canvas.width / 2, startY + i * lineHeight);
|
|
});
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: false
|
|
});
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
sprite.scale.set(CONFIG.CHAT.SPRITE_WIDTH, CONFIG.CHAT.SPRITE_HEIGHT, 1);
|
|
bubbleGroup.add(sprite);
|
|
|
|
this.chatBubble = { group: bubbleGroup, sprite: sprite };
|
|
this.mesh.add(bubbleGroup);
|
|
|
|
this.chatTimeout = setTimeout(() => {
|
|
if (this.chatBubble) {
|
|
this.mesh.remove(this.chatBubble.group);
|
|
this.chatBubble = null;
|
|
}
|
|
}, CONFIG.CHAT.DISPLAY_DURATION);
|
|
}
|
|
|
|
update(delta) {
|
|
if (this.isLocal) {
|
|
this.updateKeyboardMove(delta);
|
|
this.updateClickMove(delta);
|
|
airWallSystem.updateChar(this, delta);
|
|
|
|
sunLight.position.set(this.position.x + 30, 50, this.position.z + 30);
|
|
sunLight.target.position.copy(this.position);
|
|
sunLight.target.updateMatrixWorld();
|
|
} else {
|
|
this.position.lerp(this.networkPosition, delta * CONFIG.POSITION_LERP_SPEED);
|
|
|
|
let diff = this.networkRotationY - this.rotation.y;
|
|
while (diff > Math.PI) diff -= Math.PI * 2;
|
|
while (diff < -Math.PI) diff += Math.PI * 2;
|
|
this.rotation.y += diff * Math.min(delta * CONFIG.POSITION_LERP_SPEED, 1);
|
|
}
|
|
|
|
this.mesh.position.set(
|
|
this.position.x,
|
|
this.position.y + CONFIG.CHARACTER.GROUND_OFFSET,
|
|
this.position.z
|
|
);
|
|
this.mesh.rotation.copy(this.rotation);
|
|
|
|
this.updateHead(delta);
|
|
this.updateAnim(delta);
|
|
this.updateJump(delta);
|
|
|
|
if (this.chatBubble) {
|
|
this.chatBubble.group.lookAt(camera.position);
|
|
}
|
|
}
|
|
|
|
updateKeyboardMove(delta) {
|
|
if (this.isBigPose || chatSystem.isActive) return;
|
|
|
|
const moveDir = new THREE.Vector3();
|
|
const cy = cameraController.yaw;
|
|
|
|
if (keys.w) {
|
|
moveDir.x -= Math.sin(cy);
|
|
moveDir.z -= Math.cos(cy);
|
|
}
|
|
if (keys.s) {
|
|
moveDir.x += Math.sin(cy);
|
|
moveDir.z += Math.cos(cy);
|
|
}
|
|
if (keys.a) {
|
|
moveDir.x += Math.sin(cy - Math.PI / 2);
|
|
moveDir.z += Math.cos(cy - Math.PI / 2);
|
|
}
|
|
if (keys.d) {
|
|
moveDir.x += Math.sin(cy + Math.PI / 2);
|
|
moveDir.z += Math.cos(cy + Math.PI / 2);
|
|
}
|
|
|
|
if (moveDir.lengthSq() > 0.001) {
|
|
moveDir.normalize();
|
|
const speed = keys.shift ? CONFIG.RUN_SPEED : CONFIG.WALK_SPEED;
|
|
|
|
this.position.x += moveDir.x * speed * delta;
|
|
this.position.z += moveDir.z * speed * delta;
|
|
this.isMoving = true;
|
|
this.isRunning = keys.shift;
|
|
|
|
const targetRot = Math.atan2(moveDir.x, moveDir.z);
|
|
let rotDiff = targetRot - this.rotation.y;
|
|
while (rotDiff > Math.PI) rotDiff -= Math.PI * 2;
|
|
while (rotDiff < -Math.PI) rotDiff += Math.PI * 2;
|
|
this.rotation.y += rotDiff * Math.min(CONFIG.ROTATION_SPEED * delta * 2, 1);
|
|
|
|
this.targetPosition = null;
|
|
|
|
if (keys.shift && Math.random() > 0.7) {
|
|
createDustParticle(this.position);
|
|
}
|
|
} else if (this.targetPosition === null) {
|
|
this.isMoving = false;
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
|
|
updateClickMove(delta) {
|
|
if (!this.targetPosition) return;
|
|
|
|
const speed = this.isRunning ? CONFIG.RUN_SPEED : CONFIG.WALK_SPEED;
|
|
const dir = new THREE.Vector3().subVectors(this.targetPosition, this.position);
|
|
dir.y = 0;
|
|
const dist = dir.length();
|
|
|
|
if (dist > 0.1) {
|
|
dir.normalize();
|
|
this.position.add(dir.clone().multiplyScalar(speed * delta));
|
|
this.isMoving = true;
|
|
|
|
let targetRot = Math.atan2(dir.x, dir.z);
|
|
let rotDiff = targetRot - this.rotation.y;
|
|
while (rotDiff > Math.PI) rotDiff -= Math.PI * 2;
|
|
while (rotDiff < -Math.PI) rotDiff += Math.PI * 2;
|
|
this.rotation.y += rotDiff * Math.min(CONFIG.ROTATION_SPEED * delta, 1);
|
|
} else {
|
|
this.isMoving = false;
|
|
this.isRunning = false;
|
|
this.targetPosition = null;
|
|
}
|
|
}
|
|
|
|
updateHead(delta) {
|
|
if (this.isLocal && !this.isBigPose && !this.isJumping) {
|
|
const headPos = new THREE.Vector3();
|
|
this.head.getWorldPosition(headPos);
|
|
|
|
const target = mouseWorldPos.clone();
|
|
target.y = headPos.y;
|
|
|
|
const lookDir = new THREE.Vector3().subVectors(target, headPos).normalize();
|
|
let headRotY = Math.atan2(lookDir.x, lookDir.z) - this.rotation.y;
|
|
|
|
while (headRotY > Math.PI) headRotY -= Math.PI * 2;
|
|
while (headRotY < -Math.PI) headRotY += Math.PI * 2;
|
|
headRotY = Math.max(-0.8, Math.min(0.8, headRotY));
|
|
|
|
this.head.rotation.y = THREE.MathUtils.lerp(this.head.rotation.y, headRotY, delta * 8);
|
|
} else {
|
|
this.head.rotation.y *= 1 - delta * 5;
|
|
}
|
|
}
|
|
|
|
updateAnim(delta) {
|
|
if (this.isBigPose) {
|
|
this.bigPoseTime += delta * 2;
|
|
const breathe = Math.sin(this.bigPoseTime) * 0.03;
|
|
const sway = Math.sin(this.bigPoseTime * 0.7) * 0.02;
|
|
|
|
this.leftArm.rotation.z = THREE.MathUtils.lerp(this.leftArm.rotation.z, -Math.PI / 2 + breathe, delta * 8);
|
|
this.rightArm.rotation.z = THREE.MathUtils.lerp(this.rightArm.rotation.z, Math.PI / 2 - breathe, delta * 8);
|
|
this.leftLeg.rotation.z = THREE.MathUtils.lerp(this.leftLeg.rotation.z, -Math.PI / 4 + sway, delta * 8);
|
|
this.rightLeg.rotation.z = THREE.MathUtils.lerp(this.rightLeg.rotation.z, Math.PI / 4 - sway, delta * 8);
|
|
|
|
this.leftArm.rotation.x = THREE.MathUtils.lerp(this.leftArm.rotation.x, 0, delta * 8);
|
|
this.rightArm.rotation.x = THREE.MathUtils.lerp(this.rightArm.rotation.x, 0, delta * 8);
|
|
this.leftLeg.rotation.x = THREE.MathUtils.lerp(this.leftLeg.rotation.x, 0, delta * 8);
|
|
this.rightLeg.rotation.x = THREE.MathUtils.lerp(this.rightLeg.rotation.x, 0, delta * 8);
|
|
this.head.position.y = THREE.MathUtils.lerp(this.head.position.y, 1.575, delta * 8);
|
|
return;
|
|
}
|
|
|
|
this.bigPoseTime = 0;
|
|
|
|
if (this.isJumping) {
|
|
const rising = this.jumpVelocity > 0;
|
|
this.bodyContainer.rotation.x = THREE.MathUtils.lerp(
|
|
this.bodyContainer.rotation.x, rising ? 0.15 : 0.1, delta * 8);
|
|
|
|
const armRaise = rising ? 2.2 : 1.8;
|
|
this.leftArm.rotation.x = THREE.MathUtils.lerp(this.leftArm.rotation.x, -armRaise, delta * 10);
|
|
this.rightArm.rotation.x = THREE.MathUtils.lerp(this.rightArm.rotation.x, -armRaise, delta * 10);
|
|
this.leftArm.rotation.z = THREE.MathUtils.lerp(this.leftArm.rotation.z, -0.3, delta * 10);
|
|
this.rightArm.rotation.z = THREE.MathUtils.lerp(this.rightArm.rotation.z, 0.3, delta * 10);
|
|
|
|
const legBend = rising ? -0.5 : -0.3;
|
|
this.leftLeg.rotation.x = THREE.MathUtils.lerp(this.leftLeg.rotation.x, legBend, delta * 10);
|
|
this.rightLeg.rotation.x = THREE.MathUtils.lerp(this.rightLeg.rotation.x, legBend, delta * 10);
|
|
this.leftLeg.rotation.z = THREE.MathUtils.lerp(this.leftLeg.rotation.z, 0.15, delta * 8);
|
|
this.rightLeg.rotation.z = THREE.MathUtils.lerp(this.rightLeg.rotation.z, -0.15, delta * 8);
|
|
|
|
this.head.rotation.x = THREE.MathUtils.lerp(
|
|
this.head.rotation.x, -this.bodyContainer.rotation.x * 0.5, delta * 8);
|
|
this.head.position.y = THREE.MathUtils.lerp(this.head.position.y, 1.575, delta * 8);
|
|
return;
|
|
}
|
|
|
|
this.leftArm.rotation.z = THREE.MathUtils.lerp(this.leftArm.rotation.z, 0, delta * 6);
|
|
this.rightArm.rotation.z = THREE.MathUtils.lerp(this.rightArm.rotation.z, 0, delta * 6);
|
|
this.leftLeg.rotation.z = THREE.MathUtils.lerp(this.leftLeg.rotation.z, 0, delta * 6);
|
|
this.rightLeg.rotation.z = THREE.MathUtils.lerp(this.rightLeg.rotation.z, 0, delta * 6);
|
|
this.bodyContainer.rotation.x = THREE.MathUtils.lerp(this.bodyContainer.rotation.x, 0, delta * 6);
|
|
this.head.position.y = THREE.MathUtils.lerp(this.head.position.y, 1.575, delta * 6);
|
|
|
|
if (this.isMoving) {
|
|
const animSpeed = this.isRunning ? 15 : 8;
|
|
const limbAngle = this.isRunning ? 0.8 : 0.5;
|
|
this.walkCycle += delta * animSpeed;
|
|
|
|
this.leftArm.rotation.x = Math.sin(this.walkCycle) * limbAngle;
|
|
this.rightArm.rotation.x = Math.sin(this.walkCycle + Math.PI) * limbAngle;
|
|
this.leftLeg.rotation.x = Math.sin(this.walkCycle + Math.PI) * limbAngle;
|
|
this.rightLeg.rotation.x = Math.sin(this.walkCycle) * limbAngle;
|
|
} else {
|
|
this.walkCycle = 0;
|
|
this.leftArm.rotation.x = THREE.MathUtils.lerp(this.leftArm.rotation.x, 0, delta * 5);
|
|
this.rightArm.rotation.x = THREE.MathUtils.lerp(this.rightArm.rotation.x, 0, delta * 5);
|
|
this.leftLeg.rotation.x = THREE.MathUtils.lerp(this.leftLeg.rotation.x, 0, delta * 5);
|
|
this.rightLeg.rotation.x = THREE.MathUtils.lerp(this.rightLeg.rotation.x, 0, delta * 5);
|
|
}
|
|
|
|
if (this.isGreeting) {
|
|
this.greetingTime += delta * 10;
|
|
this.rightArm.rotation.z = Math.PI - 0.3 + Math.sin(this.greetingTime) * 0.3;
|
|
this.rightArm.rotation.x = 0;
|
|
} else {
|
|
this.greetingTime = 0;
|
|
}
|
|
|
|
if (!this.isMoving && !this.isGreeting) {
|
|
this.idleTime += delta * 2;
|
|
const sway = Math.sin(this.idleTime) * 0.02;
|
|
this.leftArm.rotation.z = sway * 2;
|
|
this.rightArm.rotation.z = -sway * 2;
|
|
this.head.position.y = 1.575 + Math.sin(this.idleTime * 1.5) * 0.01;
|
|
} else {
|
|
this.idleTime = 0;
|
|
}
|
|
}
|
|
|
|
updateJump(delta) {
|
|
if (this.isJumping) {
|
|
this.position.y += this.jumpVelocity * delta;
|
|
this.jumpVelocity -= CONFIG.GRAVITY * delta;
|
|
|
|
if (this.position.y <= 0) {
|
|
this.position.y = 0;
|
|
this.isJumping = false;
|
|
this.jumpVelocity = 0;
|
|
this.bodyContainer.rotation.x = 0;
|
|
this.head.rotation.x = 0;
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
createDustParticle(new THREE.Vector3(
|
|
this.position.x + (Math.random() - 0.5) * 0.8,
|
|
0,
|
|
this.position.z + (Math.random() - 0.5) * 0.8
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
walkTo(pos) {
|
|
this.targetPosition = pos.clone();
|
|
this.isMoving = true;
|
|
this.isRunning = false;
|
|
this.isBigPose = false;
|
|
}
|
|
|
|
switchToRun() {
|
|
this.isRunning = true;
|
|
this.isBigPose = false;
|
|
}
|
|
|
|
setNetworkState(state) {
|
|
this.networkPosition.set(state.x, state.y, state.z);
|
|
this.networkRotationY = state.rotationY;
|
|
this.isMoving = state.isMoving;
|
|
this.isRunning = state.isRunning;
|
|
this.isBigPose = state.isBigPose;
|
|
if (state.isGreeting) this.greet();
|
|
if (state.isJumping && !this.isJumping) this.jump();
|
|
}
|
|
|
|
greet() {
|
|
if (!this.isGreeting) {
|
|
this.isGreeting = true;
|
|
this.isBigPose = false;
|
|
setTimeout(() => { this.isGreeting = false; }, 1500);
|
|
}
|
|
}
|
|
|
|
jump() {
|
|
if (!this.isJumping && !this.isBigPose) {
|
|
this.isJumping = true;
|
|
this.jumpVelocity = CONFIG.JUMP_FORCE;
|
|
this.isGreeting = false;
|
|
this.targetPosition = null;
|
|
}
|
|
}
|
|
|
|
toggleBigPose() {
|
|
this.isBigPose = !this.isBigPose;
|
|
if (this.isBigPose) {
|
|
this.isMoving = false;
|
|
this.isRunning = false;
|
|
this.targetPosition = null;
|
|
this.isGreeting = false;
|
|
}
|
|
}
|
|
|
|
remove() {
|
|
if (this.chatBubble) {
|
|
this.mesh.remove(this.chatBubble.group);
|
|
}
|
|
if (this.chatTimeout) {
|
|
clearTimeout(this.chatTimeout);
|
|
}
|
|
scene.remove(this.mesh);
|
|
}
|
|
}
|
|
|
|
// ============ 全局变量 ============
|
|
let localPlayer = null;
|
|
const otherPlayers = new Map();
|
|
let heartbeatInterval = null;
|
|
|
|
// ============ 网络 ============
|
|
function publishState() {
|
|
if (!localPlayer || !multiplayerEnabled || !pubnub) return;
|
|
|
|
pubnub.publish({
|
|
channel: roomId,
|
|
message: {
|
|
type: 'update',
|
|
id: myId,
|
|
state: {
|
|
x: localPlayer.position.x,
|
|
y: localPlayer.position.y,
|
|
z: localPlayer.position.z,
|
|
rotationY: localPlayer.rotation.y,
|
|
isMoving: localPlayer.isMoving,
|
|
isRunning: localPlayer.isRunning,
|
|
isGreeting: localPlayer.isGreeting,
|
|
isJumping: localPlayer.isJumping,
|
|
isBigPose: localPlayer.isBigPose
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function initMultiplayer() {
|
|
if (!multiplayerEnabled || !pubnub) return;
|
|
|
|
pubnub.subscribe({ channels: [roomId], withPresence: true });
|
|
|
|
pubnub.addListener({
|
|
message: function(event) {
|
|
const msg = event.message;
|
|
if (msg.id === myId) return;
|
|
|
|
if (msg.type === 'chat') {
|
|
const player = otherPlayers.get(msg.id);
|
|
if (player) {
|
|
player.showChatBubble(msg.text);
|
|
}
|
|
} else if (msg.type === 'join' || msg.type === 'update') {
|
|
let player = otherPlayers.get(msg.id);
|
|
if (!player) {
|
|
player = new Character(msg.id, false);
|
|
otherPlayers.set(msg.id, player);
|
|
}
|
|
if (msg.state) {
|
|
player.setNetworkState(msg.state);
|
|
}
|
|
} else if (msg.type === 'leave') {
|
|
const player = otherPlayers.get(msg.id);
|
|
if (player) {
|
|
player.remove();
|
|
otherPlayers.delete(msg.id);
|
|
}
|
|
}
|
|
},
|
|
presence: function(event) {
|
|
if (event.action === 'leave' || event.action === 'timeout') {
|
|
const player = otherPlayers.get(event.uuid);
|
|
if (player) {
|
|
player.remove();
|
|
otherPlayers.delete(event.uuid);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
pubnub.publish({
|
|
channel: roomId,
|
|
message: {
|
|
type: 'join',
|
|
id: myId,
|
|
state: {
|
|
x: 0, y: 0, z: 0, rotationY: 0,
|
|
isMoving: false, isRunning: false,
|
|
isGreeting: false, isJumping: false, isBigPose: false
|
|
}
|
|
}
|
|
});
|
|
|
|
heartbeatInterval = setInterval(publishState, CONFIG.HEARTBEAT_INTERVAL);
|
|
}
|
|
|
|
// ============ 输入事件 ============
|
|
let lastClickTime = 0;
|
|
let lastClickPos = null;
|
|
|
|
window.addEventListener('keydown', function(e) {
|
|
if (chatSystem.isActive) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
chatSystem.send();
|
|
} else if (e.key === 'Escape') {
|
|
chatSystem.hide();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const key = e.key.toLowerCase();
|
|
if (key === 'w') keys.w = true;
|
|
if (key === 'a') keys.a = true;
|
|
if (key === 's') keys.s = true;
|
|
if (key === 'd') keys.d = true;
|
|
if (e.key === 'Shift') keys.shift = true;
|
|
|
|
if (key === 'i' && localPlayer) {
|
|
localPlayer.greet();
|
|
publishState();
|
|
}
|
|
if (key === 'q' && localPlayer) {
|
|
localPlayer.toggleBigPose();
|
|
publishState();
|
|
}
|
|
if (e.code === 'Space' && localPlayer) {
|
|
e.preventDefault();
|
|
if (!localPlayer.isJumping && !localPlayer.isBigPose) {
|
|
localPlayer.jump();
|
|
publishState();
|
|
}
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
chatSystem.show();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', function(e) {
|
|
if (chatSystem.isActive) return;
|
|
|
|
const key = e.key.toLowerCase();
|
|
if (key === 'w') keys.w = false;
|
|
if (key === 'a') keys.a = false;
|
|
if (key === 's') keys.s = false;
|
|
if (key === 'd') keys.d = false;
|
|
if (e.key === 'Shift') keys.shift = false;
|
|
});
|
|
|
|
window.addEventListener('click', function(e) {
|
|
if (chatSystem.isActive) return;
|
|
if (!localPlayer || e.button !== 0) return;
|
|
|
|
const now = Date.now();
|
|
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const hits = raycaster.intersectObject(ground);
|
|
|
|
if (hits.length > 0) {
|
|
const pt = hits[0].point;
|
|
const isDoubleClick = now - lastClickTime < CONFIG.DOUBLE_CLICK_TIMEOUT &&
|
|
lastClickPos && pt.distanceTo(lastClickPos) < 2;
|
|
|
|
if (isDoubleClick) {
|
|
localPlayer.switchToRun();
|
|
runRing.position.set(pt.x, 0.01, pt.z);
|
|
runRing.visible = true;
|
|
runRing.material.opacity = 0.9;
|
|
runRing.scale.set(1, 1, 1);
|
|
lastClickTime = 0;
|
|
lastClickPos = null;
|
|
} else {
|
|
localPlayer.walkTo(pt);
|
|
walkRing.position.set(pt.x, 0.01, pt.z);
|
|
walkRing.visible = true;
|
|
walkRing.material.opacity = 0.8;
|
|
walkRing.scale.set(1, 1, 1);
|
|
lastClickTime = now;
|
|
lastClickPos = pt.clone();
|
|
}
|
|
}
|
|
});
|
|
|
|
let lastRClick = 0;
|
|
window.addEventListener('mousedown', function(e) {
|
|
if (chatSystem.isActive) return;
|
|
|
|
if (e.button === 2) {
|
|
const now = Date.now();
|
|
if (now - lastRClick < CONFIG.DOUBLE_CLICK_TIMEOUT) {
|
|
cameraController.targetYaw = CONFIG.CAMERA.DEFAULT_YAW;
|
|
cameraController.targetPitch = CONFIG.CAMERA.DEFAULT_PITCH;
|
|
cameraController.targetDistance = CONFIG.CAMERA.DEFAULT_DISTANCE;
|
|
lastRClick = 0;
|
|
} else {
|
|
cameraController.startDrag(e.clientX, e.clientY);
|
|
lastRClick = now;
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mouseup', function(e) {
|
|
if (e.button === 2) {
|
|
cameraController.endDrag();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('mousemove', function(e) {
|
|
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const hits = raycaster.intersectObject(ground);
|
|
if (hits.length > 0) {
|
|
mouseWorldPos.copy(hits[0].point);
|
|
}
|
|
|
|
cameraController.updateDrag(e.clientX, e.clientY);
|
|
});
|
|
|
|
window.addEventListener('wheel', function(e) {
|
|
if (chatSystem.isActive) return;
|
|
e.preventDefault();
|
|
cameraController.zoom(e.deltaY, e.deltaMode);
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('contextmenu', function(e) {
|
|
e.preventDefault();
|
|
});
|
|
|
|
window.addEventListener('resize', function() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
window.addEventListener('beforeunload', function() {
|
|
if (multiplayerEnabled && pubnub) {
|
|
pubnub.publish({ channel: roomId, message: { type: 'leave', id: myId } });
|
|
clearInterval(heartbeatInterval);
|
|
}
|
|
});
|
|
|
|
// ============ 动画循环 ============
|
|
const clock = new THREE.Clock();
|
|
const curCamPos = camera.position.clone();
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
const delta = Math.min(clock.getDelta(), 0.1);
|
|
|
|
if (localPlayer) {
|
|
localPlayer.update(delta);
|
|
}
|
|
|
|
otherPlayers.forEach(function(player) {
|
|
player.update(delta);
|
|
});
|
|
|
|
updateDust(delta);
|
|
airWallSystem.update(delta);
|
|
|
|
if (walkRing.visible) {
|
|
walkRing.material.opacity -= delta * 2;
|
|
walkRing.scale.addScalar(delta * 0.5);
|
|
if (walkRing.material.opacity <= 0) {
|
|
walkRing.visible = false;
|
|
}
|
|
}
|
|
|
|
if (runRing.visible) {
|
|
runRing.material.opacity -= delta * 1.5;
|
|
runRing.scale.addScalar(delta * 0.8);
|
|
if (runRing.material.opacity <= 0) {
|
|
runRing.visible = false;
|
|
}
|
|
}
|
|
|
|
cameraController.update(delta);
|
|
|
|
if (localPlayer) {
|
|
const targetPos = new THREE.Vector3(
|
|
localPlayer.position.x,
|
|
localPlayer.position.y + 1.2,
|
|
localPlayer.position.z
|
|
);
|
|
const idealPos = cameraController.calcPos(targetPos);
|
|
curCamPos.lerp(idealPos, 1 - Math.exp(-CONFIG.CAMERA.SMOOTHING * 60 * delta));
|
|
camera.position.copy(curCamPos);
|
|
camera.lookAt(targetPos);
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// ============ 初始化 ============
|
|
function init() {
|
|
chatSystem.init();
|
|
localPlayer = new Character('local', true);
|
|
initMultiplayer();
|
|
camera.position.set(0, 8, 12);
|
|
document.body.classList.add('camera-ready');
|
|
|
|
setTimeout(function() {
|
|
document.getElementById('loading-screen').classList.add('hidden');
|
|
}, 500);
|
|
|
|
animate();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html> |