ai-chatbot/动画效果.html
Your Name 5568325a0d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add configurable home animation module
2026-03-24 22:53:16 +08:00

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>