|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>WebSocket 3D 聊天客户端</title>
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
|
|
|
<style>
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
0%, 100% { opacity: 1; }
|
|
|
|
|
50% { opacity: 0.5; }
|
|
|
|
|
}
|
|
|
|
|
@keyframes slideIn {
|
|
|
|
|
from { transform: translateY(20px); opacity: 0; }
|
|
|
|
|
to { transform: translateY(0); opacity: 1; }
|
|
|
|
|
}
|
|
|
|
|
.status-dot {
|
|
|
|
|
animation: pulse 2s infinite;
|
|
|
|
|
}
|
|
|
|
|
.message {
|
|
|
|
|
animation: slideIn 0.3s ease-out;
|
|
|
|
|
}
|
|
|
|
|
#three-container {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
z-index: -1;
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
}
|
|
|
|
|
.content-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
z-index: 1;
|
|
|
|
|
}
|
|
|
|
|
.stats-card {
|
|
|
|
|
background: rgba(255, 255, 255, 0.9);
|
|
|
|
|
backdrop-filter: blur(5px);
|
|
|
|
|
}
|
|
|
|
|
.dark .stats-card {
|
|
|
|
|
background: rgba(31, 41, 55, 0.9);
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body class="bg-gray-50 dark:bg-gray-900 h-screen flex flex-col">
|
|
|
|
|
<!-- Three.js 容器 -->
|
|
|
|
|
<div id="three-container"></div>
|
|
|
|
|
|
|
|
|
|
<!-- 内容容器 -->
|
|
|
|
|
<div class="content-container flex flex-col h-full">
|
|
|
|
|
<!-- 顶部状态栏 -->
|
|
|
|
|
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
|
|
|
|
<div class="max-w-4xl mx-auto px-4 py-3">
|
|
|
|
|
<!-- WebSocket地址输入区域 -->
|
|
|
|
|
<div class="flex items-center space-x-2 mb-3">
|
|
|
|
|
<input type="text"
|
|
|
|
|
id="wsAddress"
|
|
|
|
|
value="ws://localhost:5202/ws"
|
|
|
|
|
placeholder="输入WebSocket地址"
|
|
|
|
|
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600
|
|
|
|
|
rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
|
|
|
dark:bg-gray-700 dark:text-white transition-colors duration-200">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex justify-between items-center">
|
|
|
|
|
<div class="flex items-center space-x-2">
|
|
|
|
|
<div id="status" class="flex items-center space-x-2">
|
|
|
|
|
<div class="w-2 h-2 rounded-full bg-red-500 status-dot"></div>
|
|
|
|
|
<span class="text-sm text-gray-600 dark:text-gray-300">未连接</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="stats" class="flex items-center space-x-4 ml-4">
|
|
|
|
|
<span class="text-sm text-gray-600 dark:text-gray-300">
|
|
|
|
|
消息: <span id="messageCount">0</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span class="text-sm text-gray-600 dark:text-gray-300">
|
|
|
|
|
连接: <span id="connectionCount">0</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex space-x-2">
|
|
|
|
|
<button id="connectBtn"
|
|
|
|
|
class="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg shadow-sm
|
|
|
|
|
transition-colors duration-200">
|
|
|
|
|
连接
|
|
|
|
|
</button>
|
|
|
|
|
<button id="disconnectBtn"
|
|
|
|
|
class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg shadow-sm
|
|
|
|
|
transition-colors duration-200">
|
|
|
|
|
断开
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 消息区域 -->
|
|
|
|
|
<div class="flex-1 overflow-hidden">
|
|
|
|
|
<div class="max-w-4xl mx-auto h-full px-4 py-4">
|
|
|
|
|
<div class="flex justify-between items-center mb-2">
|
|
|
|
|
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-300">消息记录</h2>
|
|
|
|
|
<div class="flex space-x-2">
|
|
|
|
|
<select id="messageType"
|
|
|
|
|
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-lg
|
|
|
|
|
dark:bg-gray-700 dark:text-white">
|
|
|
|
|
<option value="message">普通消息</option>
|
|
|
|
|
<option value="system">系统消息</option>
|
|
|
|
|
<option value="error">错误消息</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button id="clearBtn"
|
|
|
|
|
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 text-white rounded-lg
|
|
|
|
|
shadow-sm transition-colors duration-200">
|
|
|
|
|
清空消息
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="messages" class="h-full overflow-y-auto space-y-4">
|
|
|
|
|
<!-- 消息将在这里动态添加 -->
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 输入区域 -->
|
|
|
|
|
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
|
|
|
|
<div class="max-w-4xl mx-auto px-4 py-4">
|
|
|
|
|
<div class="flex space-x-2">
|
|
|
|
|
<input type="text"
|
|
|
|
|
id="messageInput"
|
|
|
|
|
placeholder="输入消息..."
|
|
|
|
|
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600
|
|
|
|
|
rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500
|
|
|
|
|
dark:bg-gray-700 dark:text-white transition-colors duration-200">
|
|
|
|
|
<button id="sendBtn"
|
|
|
|
|
class="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg
|
|
|
|
|
shadow-sm transition-colors duration-200">
|
|
|
|
|
发送
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
// Three.js 场景初始化
|
|
|
|
|
let scene, camera, renderer, particles;
|
|
|
|
|
const particleCount = 1000;
|
|
|
|
|
const particleGeometry = new THREE.BufferGeometry();
|
|
|
|
|
const particlePositions = new Float32Array(particleCount * 3);
|
|
|
|
|
const particleColors = new Float32Array(particleCount * 3);
|
|
|
|
|
const particleVelocities = new Float32Array(particleCount * 3);
|
|
|
|
|
|
|
|
|
|
function initThreeJS() {
|
|
|
|
|
scene = new THREE.Scene();
|
|
|
|
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
|
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
|
|
|
document.getElementById('three-container').appendChild(renderer.domElement);
|
|
|
|
|
|
|
|
|
|
// 初始化粒子
|
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
|
|
|
particlePositions[i * 3] = (Math.random() - 0.5) * 20;
|
|
|
|
|
particlePositions[i * 3 + 1] = (Math.random() - 0.5) * 20;
|
|
|
|
|
particlePositions[i * 3 + 2] = (Math.random() - 0.5) * 20;
|
|
|
|
|
|
|
|
|
|
particleColors[i * 3] = Math.random();
|
|
|
|
|
particleColors[i * 3 + 1] = Math.random();
|
|
|
|
|
particleColors[i * 3 + 2] = Math.random();
|
|
|
|
|
|
|
|
|
|
particleVelocities[i * 3] = (Math.random() - 0.5) * 0.02;
|
|
|
|
|
particleVelocities[i * 3 + 1] = (Math.random() - 0.5) * 0.02;
|
|
|
|
|
particleVelocities[i * 3 + 2] = (Math.random() - 0.5) * 0.02;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
particleGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
|
|
|
|
|
particleGeometry.setAttribute('color', new THREE.BufferAttribute(particleColors, 3));
|
|
|
|
|
|
|
|
|
|
const particleMaterial = new THREE.PointsMaterial({
|
|
|
|
|
size: 0.1,
|
|
|
|
|
vertexColors: true,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.8
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
particles = new THREE.Points(particleGeometry, particleMaterial);
|
|
|
|
|
scene.add(particles);
|
|
|
|
|
|
|
|
|
|
camera.position.z = 15;
|
|
|
|
|
|
|
|
|
|
// 添加环境光
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
|
|
|
|
scene.add(ambientLight);
|
|
|
|
|
|
|
|
|
|
// 添加点光源
|
|
|
|
|
const pointLight = new THREE.PointLight(0xffffff, 1);
|
|
|
|
|
pointLight.position.set(10, 10, 10);
|
|
|
|
|
scene.add(pointLight);
|
|
|
|
|
|
|
|
|
|
animate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function animate() {
|
|
|
|
|
requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
|
|
// 更新粒子位置
|
|
|
|
|
const positions = particleGeometry.attributes.position.array;
|
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
|
|
|
positions[i * 3] += particleVelocities[i * 3];
|
|
|
|
|
positions[i * 3 + 1] += particleVelocities[i * 3 + 1];
|
|
|
|
|
positions[i * 3 + 2] += particleVelocities[i * 3 + 2];
|
|
|
|
|
|
|
|
|
|
// 边界检查
|
|
|
|
|
if (Math.abs(positions[i * 3]) > 10) particleVelocities[i * 3] *= -1;
|
|
|
|
|
if (Math.abs(positions[i * 3 + 1]) > 10) particleVelocities[i * 3 + 1] *= -1;
|
|
|
|
|
if (Math.abs(positions[i * 3 + 2]) > 10) particleVelocities[i * 3 + 2] *= -1;
|
|
|
|
|
}
|
|
|
|
|
particleGeometry.attributes.position.needsUpdate = true;
|
|
|
|
|
|
|
|
|
|
// 旋转场景
|
|
|
|
|
scene.rotation.y += 0.001;
|
|
|
|
|
|
|
|
|
|
renderer.render(scene, camera);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理窗口大小变化
|
|
|
|
|
window.addEventListener('resize', function() {
|
|
|
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// WebSocket 相关代码
|
|
|
|
|
let socket;
|
|
|
|
|
let reconnectAttempts = 0;
|
|
|
|
|
const maxReconnectAttempts = 5;
|
|
|
|
|
const reconnectDelay = 3000;
|
|
|
|
|
let messageCount = 0;
|
|
|
|
|
let connectionCount = 0;
|
|
|
|
|
|
|
|
|
|
const statusElement = document.getElementById('status');
|
|
|
|
|
const statusDot = statusElement.querySelector('.status-dot');
|
|
|
|
|
const statusText = statusElement.querySelector('span:last-child');
|
|
|
|
|
const messagesContainer = document.getElementById('messages');
|
|
|
|
|
const messageInput = document.getElementById('messageInput');
|
|
|
|
|
const connectBtn = document.getElementById('connectBtn');
|
|
|
|
|
const disconnectBtn = document.getElementById('disconnectBtn');
|
|
|
|
|
const sendBtn = document.getElementById('sendBtn');
|
|
|
|
|
const wsAddressInput = document.getElementById('wsAddress');
|
|
|
|
|
const clearBtn = document.getElementById('clearBtn');
|
|
|
|
|
const messageTypeSelect = document.getElementById('messageType');
|
|
|
|
|
const messageCountElement = document.getElementById('messageCount');
|
|
|
|
|
const connectionCountElement = document.getElementById('connectionCount');
|
|
|
|
|
|
|
|
|
|
// 更新状态显示
|
|
|
|
|
function updateStatus(connected) {
|
|
|
|
|
if (connected) {
|
|
|
|
|
statusDot.classList.remove('bg-red-500');
|
|
|
|
|
statusDot.classList.add('bg-green-500');
|
|
|
|
|
statusText.textContent = '已连接';
|
|
|
|
|
statusText.classList.remove('text-gray-600');
|
|
|
|
|
statusText.classList.add('text-green-600');
|
|
|
|
|
connectionCount++;
|
|
|
|
|
connectionCountElement.textContent = connectionCount;
|
|
|
|
|
} else {
|
|
|
|
|
statusDot.classList.remove('bg-green-500');
|
|
|
|
|
statusDot.classList.add('bg-red-500');
|
|
|
|
|
statusText.textContent = '未连接';
|
|
|
|
|
statusText.classList.remove('text-green-600');
|
|
|
|
|
statusText.classList.add('text-gray-600');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加消息到显示区域
|
|
|
|
|
function addMessage(message, isReceived = false, type = 'message') {
|
|
|
|
|
const messageElement = document.createElement('div');
|
|
|
|
|
messageElement.className = `message flex ${isReceived ? 'justify-start' : 'justify-end'}`;
|
|
|
|
|
|
|
|
|
|
const messageContent = document.createElement('div');
|
|
|
|
|
messageContent.className = `flex items-start space-x-2 max-w-[80%]`;
|
|
|
|
|
|
|
|
|
|
const avatar = document.createElement('div');
|
|
|
|
|
let avatarClass = 'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-white font-bold';
|
|
|
|
|
|
|
|
|
|
switch(type) {
|
|
|
|
|
case 'system':
|
|
|
|
|
avatarClass += ' bg-blue-500';
|
|
|
|
|
avatar.textContent = '系';
|
|
|
|
|
break;
|
|
|
|
|
case 'error':
|
|
|
|
|
avatarClass += ' bg-red-500';
|
|
|
|
|
avatar.textContent = '错';
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
avatarClass += isReceived ? ' bg-gray-500' : ' bg-indigo-500';
|
|
|
|
|
avatar.textContent = isReceived ? 'S' : '我';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
avatar.className = avatarClass;
|
|
|
|
|
|
|
|
|
|
const bubble = document.createElement('div');
|
|
|
|
|
let bubbleClass = 'p-3 rounded-lg shadow-sm';
|
|
|
|
|
|
|
|
|
|
switch(type) {
|
|
|
|
|
case 'system':
|
|
|
|
|
bubbleClass += ' bg-blue-100 dark:bg-blue-900';
|
|
|
|
|
break;
|
|
|
|
|
case 'error':
|
|
|
|
|
bubbleClass += ' bg-red-100 dark:bg-red-900';
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
bubbleClass += isReceived ? ' bg-gray-100 dark:bg-gray-700' : ' bg-indigo-100 dark:bg-indigo-900';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bubble.className = bubbleClass;
|
|
|
|
|
bubble.textContent = message;
|
|
|
|
|
|
|
|
|
|
messageContent.appendChild(avatar);
|
|
|
|
|
messageContent.appendChild(bubble);
|
|
|
|
|
messageElement.appendChild(messageContent);
|
|
|
|
|
messagesContainer.appendChild(messageElement);
|
|
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
|
|
|
|
|
|
|
|
// 更新消息计数
|
|
|
|
|
messageCount++;
|
|
|
|
|
messageCountElement.textContent = messageCount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 连接WebSocket
|
|
|
|
|
function connect() {
|
|
|
|
|
try {
|
|
|
|
|
socket = new WebSocket(wsAddressInput.value);
|
|
|
|
|
|
|
|
|
|
socket.onopen = function() {
|
|
|
|
|
updateStatus(true);
|
|
|
|
|
addMessage('连接成功', false, 'system');
|
|
|
|
|
reconnectAttempts = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onclose = function() {
|
|
|
|
|
updateStatus(false);
|
|
|
|
|
addMessage('连接已关闭', false, 'system');
|
|
|
|
|
attemptReconnect();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onerror = function(error) {
|
|
|
|
|
addMessage(`连接错误: ${error.message}`, false, 'error');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onmessage = function(event) {
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
|
addMessage(data.content, true, data.type || 'message');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
addMessage(event.data, true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
addMessage(`连接失败: ${error.message}`, false, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 尝试重新连接
|
|
|
|
|
function attemptReconnect() {
|
|
|
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
|
|
|
reconnectAttempts++;
|
|
|
|
|
addMessage(`尝试重新连接 (${reconnectAttempts}/${maxReconnectAttempts})...`, false, 'system');
|
|
|
|
|
setTimeout(connect, reconnectDelay);
|
|
|
|
|
} else {
|
|
|
|
|
addMessage('达到最大重连次数,请手动重连', false, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 发送消息
|
|
|
|
|
function sendMessage() {
|
|
|
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
|
|
|
addMessage('未连接到服务器', false, 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = messageInput.value.trim();
|
|
|
|
|
if (!message) return;
|
|
|
|
|
|
|
|
|
|
const messageType = messageTypeSelect.value;
|
|
|
|
|
const data = {
|
|
|
|
|
type: messageType,
|
|
|
|
|
content: message,
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
socket.send(JSON.stringify(data));
|
|
|
|
|
addMessage(message, false, messageType);
|
|
|
|
|
messageInput.value = '';
|
|
|
|
|
} catch (error) {
|
|
|
|
|
addMessage(`发送失败: ${error.message}`, false, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 事件监听
|
|
|
|
|
connectBtn.addEventListener('click', connect);
|
|
|
|
|
disconnectBtn.addEventListener('click', function() {
|
|
|
|
|
if (socket) {
|
|
|
|
|
socket.close();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
sendBtn.addEventListener('click', sendMessage);
|
|
|
|
|
messageInput.addEventListener('keypress', function(e) {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
sendMessage();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
clearBtn.addEventListener('click', function() {
|
|
|
|
|
messagesContainer.innerHTML = '';
|
|
|
|
|
messageCount = 0;
|
|
|
|
|
messageCountElement.textContent = '0';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 初始化Three.js
|
|
|
|
|
initThreeJS();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|