第28节:网络同步与多人在线3D场景
第28节:网络同步与多人在线3D场景
前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,可以分享一下给大家。点击跳转到网站。
https://www.captainbed.cn/ccc
概述
多人在线3D场景是现代Web应用的重要发展方向,涉及实时网络通信、状态同步、冲突检测等复杂技术。本节将深入探索WebSocket通信架构、权威服务器模式、预测与调和算法,构建稳定可靠的多人交互体验。
多人同步系统架构:
核心原理深度解析
网络同步模型
多人游戏常用的同步架构对比:
模型类型 | 架构特点 | 适用场景 | 延迟处理 |
---|---|---|---|
权威服务器 | 服务器验证所有操作 | 竞技游戏、MMO | 客户端预测+服务器调和 |
P2P对等 | 节点间直接通信 | 小规模联机 | 锁步同步、帧同步 |
混合模式 | 区域服务器+中继 | 大型开放世界 | 分区分层同步 |
同步策略选择
根据应用需求选择合适的同步粒度:
-
状态同步
- 全量状态定期同步
- 增量状态实时同步
- 关键事件立即同步
-
输入同步
- 只同步用户输入
- 服务器计算确定结果
- 客户端预测显示
完整代码实现
多人在线3D场景系统
<template><div class="multiplayer-container"><!-- 主渲染区域 --><canvas ref="renderCanvas" class="render-canvas"></canvas><!-- 连接状态面板 --><div class="connection-panel" :class="connectionStatus"><div class="status-indicator"></div><span class="status-text">{{ connectionText }}</span><span class="ping-display">Ping: {{ currentPing }}ms</span></div><!-- 玩家信息面板 --><div class="players-panel"><h4>在线玩家 ({{ playerCount }})</h4><div class="players-list"><div v-for="player in connectedPlayers" :key="player.id"class="player-item":class="{ local: player.isLocal }"><span class="player-name">{{ player.name }}</span><span class="player-ping">{{ player.ping }}ms</span></div></div></div><!-- 控制面板 --><div class="control-panel"><div class="panel-section"><h4>连接设置</h4><div class="connection-controls"><input v-model="serverAddress" placeholder="服务器地址"class="server-input"><button @click="connectToServer" :disabled="isConnected"class="connect-button">{{ isConnected ? '已连接' : '连接服务器' }}</button><button @click="disconnectFromServer" :disabled="!isConnected"class="disconnect-button">断开连接</button></div></div><div class="panel-section"><h4>同步设置</h4><div class="sync-settings"><label class="setting-item"><input type="checkbox" v-model="enablePrediction">客户端预测</label><label class="setting-item"><input type="checkbox" v-model="enableInterpolation">插值平滑</label><label class="setting-item"><input type="checkbox" v-model="enableReconciliation">状态调和</label></div></div><div class="panel-section"><h4>网络统计</h4><div class="network-stats"><div class="stat-item"><span>上行: {{ formatBytes(uploadRate) }}/s</span></div><div class="stat-item"><span>下行: {{ formatBytes(downloadRate) }}/s</span></div><div class="stat-item"><span>丢包率: {{ packetLoss }}%</span></div><div class="stat-item"><span>抖动: {{ networkJitter }}ms</span></div></div></div></div><!-- 聊天面板 --><div class="chat-panel"><div class="chat-messages"><div v-for="message in chatMessages" :key="message.id"class="chat-message":class="message.type"><span class="message-sender">{{ message.sender }}:</span><span class="message-content">{{ message.content }}</span><span class="message-time">{{ formatTime(message.timestamp) }}</span></div></div><div class="chat-input-container"><input v-model="chatInput" @keyup.enter="sendChatMessage"placeholder="输入聊天消息..."class="chat-input"><button @click="sendChatMessage" class="send-button">发送</button></div></div><!-- 连接状态遮罩 --><div v-if="showLoading" class="loading-overlay"><div class="loading-content"><div class="spinner"></div><p>{{ loadingMessage }}</p></div></div></div>
</template><script>
import { onMounted, onUnmounted, ref, reactive, computed } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';// 网络连接管理器
class NetworkManager {constructor() {this.socket = null;this.isConnected = false;this.reconnectAttempts = 0;this.maxReconnectAttempts = 5;this.reconnectInterval = 2000;this.messageHandlers = new Map();this.pendingMessages = new Map();this.setupMessageHandlers();}// 连接到服务器async connect(serverUrl) {return new Promise((resolve, reject) => {try {this.socket = new WebSocket(serverUrl);this.socket.onopen = () => {this.isConnected = true;this.reconnectAttempts = 0;console.log('WebSocket连接已建立');resolve();};this.socket.onmessage = (event) => {this.handleMessage(JSON.parse(event.data));};this.socket.onclose = () => {this.isConnected = false;console.log('WebSocket连接已关闭');this.handleDisconnection();};this.socket.onerror = (error) => {console.error('WebSocket错误:', error);reject(error);};} catch (error) {reject(error);}});}// 处理断开连接handleDisconnection() {if (this.reconnectAttempts < this.maxReconnectAttempts) {setTimeout(() => {this.reconnectAttempts++;this.connect(this.socket.url);}, this.reconnectInterval);}}// 发送消息send(messageType, data, reliable = true) {if (!this.isConnected) return false;const message = {type: messageType,data: data,timestamp: Date.now(),sequence: this.generateSequenceId()};if (reliable) {this.pendingMessages.set(message.sequence, message);}this.socket.send(JSON.stringify(message));return true;}// 注册消息处理器on(messageType, handler) {if (!this.messageHandlers.has(messageType)) {this.messageHandlers.set(messageType, []);}this.messageHandlers.get(messageType).push(handler);}// 处理接收到的消息handleMessage(message) {const handlers = this.messageHandlers.get(message.type) || [];handlers.forEach(handler => handler(message.data));// 确认可靠消息if (message.ack) {this.pendingMessages.delete(message.ack);}}// 生成序列IDgenerateSequenceId() {return Date.now().toString(36) + Math.random().toString(36).substr(2);}// 断开连接disconnect() {if (this.socket) {this.socket.close();this.socket = null;}this.isConnected = false;}
}// 实体同步管理器
class EntitySyncManager {constructor(networkManager, scene) {this.networkManager = networkManager;this.scene = scene;this.entities = new Map();this.localEntities = new Map();this.predictionBuffer = new Map();this.setupNetworkHandlers();}// 设置网络处理器setupNetworkHandlers() {this.networkManager.on('entityCreate', this.handleEntityCreate.bind(this));this.networkManager.on('entityUpdate', this.handleEntityUpdate.bind(this));this.networkManager.on('entityDestroy', this.handleEntityDestroy.bind(this));this.networkManager.on('worldState', this.handleWorldState.bind(this));}// 处理实体创建handleEntityCreate(entityData) {const entity = this.createEntity(entityData);this.entities.set(entityData.id, entity);if (entityData.owner === this.networkManager.clientId) {this.localEntities.set(entityData.id, entity);}}// 处理实体更新handleEntityUpdate(updateData) {const entity = this.entities.get(updateData.id);if (!entity) return;// 如果是本地实体,进行预测调和if (this.localEntities.has(updateData.id)) {this.reconcileEntity(entity, updateData);} else {this.applyEntityUpdate(entity, updateData);}}// 预测调和reconcileEntity(entity, serverState) {const predictedStates = this.predictionBuffer.get(entity.userData.id) || [];// 找到对应的预测状态const matchingStateIndex = predictedStates.findIndex(state => state.sequence === serverState.sequence);if (matchingStateIndex !== -1) {// 移除已确认的状态predictedStates.splice(0, matchingStateIndex + 1);// 如果有未确认的状态,重新应用if (predictedStates.length > 0) {this.reapplyPredictedStates(entity, predictedStates);}} else {// 没有匹配的预测状态,强制同步到服务器状态this.applyEntityUpdate(entity, serverState);}}// 重新应用预测状态reapplyPredictedStates(entity, predictedStates) {predictedStates.forEach(state => {this.applyEntityUpdate(entity, state, true);});}// 应用实体更新applyEntityUpdate(entity, updateData, isPrediction = false) {if (updateData.position) {if (isPrediction) {entity.position.lerp(new THREE.Vector3().fromArray(updateData.position),0.3);} else {entity.position.fromArray(updateData.position);}}if (updateData.rotation) {entity.rotation.fromArray(updateData.rotation);}if (updateData.animation) {this.updateEntityAnimation(entity, updateData.animation);}// 保存预测状态if (isPrediction && this.localEntities.has(entity.userData.id)) {this.savePredictionState(entity, updateData.sequence);}}// 创建实体createEntity(entityData) {let mesh;switch (entityData.type) {case 'player':mesh = this.createPlayerEntity(entityData);break;case 'npc':mesh = this.createNPCEntity(entityData);break;case 'item':mesh = this.createItemEntity(entityData);break;default:mesh = this.createDefaultEntity(entityData);}mesh.userData = {id: entityData.id,type: entityData.type,owner: entityData.owner,lastUpdate: Date.now()};this.scene.add(mesh);return mesh;}// 创建玩家实体createPlayerEntity(entityData) {const geometry = new THREE.CapsuleGeometry(0.5, 1, 4, 8);const material = new THREE.MeshStandardMaterial({color: entityData.color || 0x00ff00,roughness: 0.7,metalness: 0.3});const mesh = new THREE.Mesh(geometry, material);mesh.castShadow = true;// 添加玩家标签const nameLabel = this.createNameLabel(entityData.name);mesh.add(nameLabel);return mesh;}// 创建名称标签createNameLabel(name) {const canvas = document.createElement('canvas');const context = canvas.getContext('2d');canvas.width = 256;canvas.height = 64;context.fillStyle = 'rgba(0, 0, 0, 0.7)';context.fillRect(0, 0, canvas.width, canvas.height);context.font = '24px Arial';context.fillStyle = 'white';context.textAlign = 'center';context.fillText(name, canvas.width / 2, canvas.height / 2 + 8);const texture = new THREE.CanvasTexture(canvas);const material = new THREE.SpriteMaterial({ map: texture });const sprite = new THREE.Sprite(material);sprite.scale.set(2, 0.5, 1);sprite.position.y = 2;return sprite;}// 更新实体动画updateEntityAnimation(entity, animationData) {// 实现动画状态同步if (entity.userData.animationMixer) {entity.userData.animationMixer.update(animationData.deltaTime);}}// 保存预测状态savePredictionState(entity, sequence) {if (!this.predictionBuffer.has(entity.userData.id)) {this.predictionBuffer.set(entity.userData.id, []);}const buffer = this.predictionBuffer.get(entity.userData.id);buffer.push({sequence: sequence,position: entity.position.toArray(),rotation: entity.rotation.toArray(),timestamp: Date.now()});// 限制缓冲区大小if (buffer.length > 60) { // 保持1秒的预测数据buffer.shift();}}
}// 输入预测系统
class InputPredictionSystem {constructor(networkManager, entitySyncManager) {this.networkManager = networkManager;this.entitySyncManager = entitySyncManager;this.inputBuffer = [];this.lastProcessedInput = 0;this.setupInputHandlers();}// 设置输入处理器setupInputHandlers() {document.addEventListener('keydown', this.handleKeyDown.bind(this));document.addEventListener('keyup', this.handleKeyUp.bind(this));document.addEventListener('mousemove', this.handleMouseMove.bind(this));}// 处理按键按下handleKeyDown(event) {if (!this.shouldProcessInput(event)) return;const input = {type: 'keydown',key: event.key,code: event.code,timestamp: Date.now(),sequence: this.networkManager.generateSequenceId()};this.processInput(input);}// 处理按键释放handleKeyUp(event) {if (!this.shouldProcessInput(event)) return;const input = {type: 'keyup',key: event.key,code: event.code,timestamp: Date.now(),sequence: this.networkManager.generateSequenceId()};this.processInput(input);}// 处理鼠标移动handleMouseMove(event) {const input = {type: 'mousemove',movementX: event.movementX,movementY: event.movementY,timestamp: Date.now(),sequence: this.networkManager.generateSequenceId()};this.processInput(input);}// 处理输入processInput(input) {// 本地预测this.applyInputPrediction(input);// 发送到服务器this.networkManager.send('playerInput', input);// 保存到缓冲区this.inputBuffer.push(input);// 限制缓冲区大小if (this.inputBuffer.length > 120) { // 保持2秒的输入数据this.inputBuffer.shift();}}// 应用输入预测applyInputPrediction(input) {// 根据输入类型更新本地实体状态const localEntities = Array.from(this.entitySyncManager.localEntities.values());localEntities.forEach(entity => {this.updateEntityFromInput(entity, input);});}// 根据输入更新实体updateEntityFromInput(entity, input) {const speed = 0.1;const rotationSpeed = 0.02;switch (input.type) {case 'keydown':switch (input.code) {case 'KeyW':entity.position.z -= speed;break;case 'KeyS':entity.position.z += speed;break;case 'KeyA':entity.position.x -= speed;break;case 'KeyD':entity.position.x += speed;break;}break;case 'mousemove':entity.rotation.y -= input.movementX * rotationSpeed;break;}// 保存预测状态this.entitySyncManager.savePredictionState(entity, input.sequence);}// 检查是否应该处理输入shouldProcessInput(event) {// 忽略组合键和系统快捷键if (event.ctrlKey || event.altKey || event.metaKey) return false;// 忽略输入框中的输入if (event.target.tagName === 'INPUT') return false;return true;}
}export default {name: 'MultiplayerScene',setup() {const renderCanvas = ref(null);const serverAddress = ref('ws://localhost:8080');const isConnected = ref(false);const connectionStatus = ref('disconnected');const currentPing = ref(0);const playerCount = ref(0);const connectedPlayers = ref([]);const chatMessages = ref([]);const chatInput = ref('');const showLoading = ref(false);const loadingMessage = ref('');const enablePrediction = ref(true);const enableInterpolation = ref(true);const enableReconciliation = ref(true);const uploadRate = ref(0);const downloadRate = ref(0);const packetLoss = ref(0);const networkJitter = ref(0);let scene, camera, renderer, controls;let networkManager, entitySyncManager, inputPredictionSystem;let localPlayerId = null;let pingInterval = null;// 初始化场景const initScene = async () => {// 创建场景scene = new THREE.Scene();scene.background = new THREE.Color(0x87CEEB);scene.fog = new THREE.Fog(0x87CEEB, 10, 100);// 创建相机camera = new THREE.PerspectiveCamera(75,window.innerWidth / window.innerHeight,0.1,1000);camera.position.set(0, 10, 10);// 创建渲染器renderer = new THREE.WebGLRenderer({canvas: renderCanvas.value,antialias: true});renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));renderer.shadowMap.enabled = true;renderer.shadowMap.type = THREE.PCFSoftShadowMap;// 添加控制器controls = new OrbitControls(camera, renderer.domElement);controls.enableDamping = true;// 创建环境createEnvironment();// 初始化网络系统initNetworkSystems();// 启动渲染循环animate();};// 创建环境const createEnvironment = () => {// 添加环境光const ambientLight = new THREE.AmbientLight(0x404040, 0.6);scene.add(ambientLight);// 添加方向光const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight.position.set(50, 50, 25);directionalLight.castShadow = true;directionalLight.shadow.mapSize.set(2048, 2048);scene.add(directionalLight);// 创建地面const groundGeometry = new THREE.PlaneGeometry(100, 100);const groundMaterial = new THREE.MeshStandardMaterial({color: 0x90EE90,roughness: 0.8,metalness: 0.2});const ground = new THREE.Mesh(groundGeometry, groundMaterial);ground.rotation.x = -Math.PI / 2;ground.receiveShadow = true;scene.add(ground);// 添加一些障碍物addEnvironmentObjects();};// 添加环境物体const addEnvironmentObjects = () => {const obstacleGeometry = new THREE.BoxGeometry(2, 2, 2);const obstacleMaterial = new THREE.MeshStandardMaterial({color: 0x8B4513,roughness: 0.7});for (let i = 0; i < 10; i++) {const obstacle = new THREE.Mesh(obstacleGeometry, obstacleMaterial);obstacle.position.set((Math.random() - 0.5) * 80,1,(Math.random() - 0.5) * 80);obstacle.castShadow = true;scene.add(obstacle);}};// 初始化网络系统const initNetworkSystems = () => {networkManager = new NetworkManager();entitySyncManager = new EntitySyncManager(networkManager, scene);inputPredictionSystem = new InputPredictionSystem(networkManager, entitySyncManager);setupNetworkEventHandlers();};// 设置网络事件处理器const setupNetworkEventHandlers = () => {networkManager.on('connectionEstablished', (data) => {console.log('连接已建立:', data);isConnected.value = true;connectionStatus.value = 'connected';localPlayerId = data.clientId;startPingMeasurement();});networkManager.on('playerJoined', (playerData) => {console.log('玩家加入:', playerData);addOrUpdatePlayer(playerData);});networkManager.on('playerLeft', (playerId) => {console.log('玩家离开:', playerId);removePlayer(playerId);});networkManager.on('chatMessage', (messageData) => {addChatMessage(messageData);});networkManager.on('pingResponse', (data) => {currentPing.value = Date.now() - data.sendTime;});};// 连接到服务器const connectToServer = async () => {showLoading.value = true;loadingMessage.value = '正在连接服务器...';try {await networkManager.connect(serverAddress.value);loadingMessage.value = '连接成功,正在初始化...';// 模拟加载过程setTimeout(() => {showLoading.value = false;}, 2000);} catch (error) {console.error('连接失败:', error);loadingMessage.value = `连接失败: ${error.message}`;setTimeout(() => {showLoading.value = false;}, 3000);}};// 断开连接const disconnectFromServer = () => {if (pingInterval) {clearInterval(pingInterval);pingInterval = null;}networkManager.disconnect();isConnected.value = false;connectionStatus.value = 'disconnected';connectedPlayers.value = [];playerCount.value = 0;};// 开始ping测量const startPingMeasurement = () => {pingInterval = setInterval(() => {networkManager.send('ping', { sendTime: Date.now() });}, 1000);};// 添加或更新玩家const addOrUpdatePlayer = (playerData) => {const existingIndex = connectedPlayers.value.findIndex(p => p.id === playerData.id);if (existingIndex !== -1) {connectedPlayers.value[existingIndex] = {...connectedPlayers.value[existingIndex],...playerData};} else {connectedPlayers.value.push({...playerData,isLocal: playerData.id === localPlayerId});}playerCount.value = connectedPlayers.value.length;};// 移除玩家const removePlayer = (playerId) => {connectedPlayers.value = connectedPlayers.value.filter(p => p.id !== playerId);playerCount.value = connectedPlayers.value.length;};// 发送聊天消息const sendChatMessage = () => {if (!chatInput.value.trim() || !isConnected.value) return;const messageData = {content: chatInput.value,sender: '本地玩家', // 实际应该从服务器获取玩家名称timestamp: Date.now()};networkManager.send('chatMessage', messageData);chatInput.value = '';};// 添加聊天消息const addChatMessage = (messageData) => {chatMessages.value.push({...messageData,id: Date.now().toString(),type: messageData.sender === '本地玩家' ? 'local' : 'remote'});// 限制消息数量if (chatMessages.value.length > 50) {chatMessages.value.shift();}};// 格式化时间const formatTime = (timestamp) => {return new Date(timestamp).toLocaleTimeString();};// 格式化字节大小const formatBytes = (bytes) => {if (bytes === 0) return '0 B';const k = 1024;const sizes = ['B', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];};// 连接状态文本const connectionText = computed(() => {switch (connectionStatus.value) {case 'connected': return '已连接';case 'connecting': return '连接中...';case 'disconnected': return '未连接';default: return '未知状态';}});// 动画循环const animate = () => {requestAnimationFrame(animate);// 更新控制器controls.update();// 更新网络统计(模拟数据)updateNetworkStats();// 渲染场景renderer.render(scene, camera);};// 更新网络统计const updateNetworkStats = () => {// 模拟网络统计数据if (isConnected.value) {uploadRate.value = Math.random() * 1024 * 10;downloadRate.value = Math.random() * 1024 * 50;packetLoss.value = Math.random() * 2;networkJitter.value = Math.random() * 10;}};onMounted(() => {initScene();window.addEventListener('resize', handleResize);});onUnmounted(() => {if (networkManager) {networkManager.disconnect();}if (pingInterval) {clearInterval(pingInterval);}window.removeEventListener('resize', handleResize);});const handleResize = () => {if (!camera || !renderer) return;camera.aspect = window.innerWidth / window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth, window.innerHeight);};return {renderCanvas,serverAddress,isConnected,connectionStatus,currentPing,playerCount,connectedPlayers,chatMessages,chatInput,showLoading,loadingMessage,enablePrediction,enableInterpolation,enableReconciliation,uploadRate,downloadRate,packetLoss,networkJitter,connectionText,connectToServer,disconnectFromServer,sendChatMessage,formatTime,formatBytes};}
};
</script><style scoped>
.multiplayer-container {width: 100%;height: 100vh;position: relative;background: #000;
}.render-canvas {width: 100%;height: 100%;display: block;
}.connection-panel {position: absolute;top: 20px;left: 20px;display: flex;align-items: center;gap: 10px;padding: 10px 15px;background: rgba(0, 0, 0, 0.8);border-radius: 8px;color: white;backdrop-filter: blur(10px);border: 1px solid rgba(255, 255, 255, 0.1);
}.connection-panel.connected {border-color: #00ff00;
}.connection-panel.connecting {border-color: #ffff00;
}.connection-panel.disconnected {border-color: #ff0000;
}.status-indicator {width: 8px;height: 8px;border-radius: 50%;
}.connected .status-indicator {background: #00ff00;box-shadow: 0 0 10px #00ff00;
}.connecting .status-indicator {background: #ffff00;box-shadow: 0 0 10px #ffff00;
}.disconnected .status-indicator {background: #ff0000;box-shadow: 0 0 10px #ff0000;
}.status-text {font-weight: bold;
}.ping-display {color: #ccc;font-size: 12px;
}.players-panel {position: absolute;top: 80px;left: 20px;width: 200px;background: rgba(0, 0, 0, 0.8);border-radius: 8px;padding: 15px;color: white;backdrop-filter: blur(10px);
}.players-panel h4 {margin: 0 0 10px 0;color: #00ffff;font-size: 14px;
}.players-list {display: flex;flex-direction: column;gap: 5px;
}.player-item {display: flex;justify-content: space-between;align-items: center;padding: 5px 8px;background: rgba(255, 255, 255, 0.1);border-radius: 4px;font-size: 12px;
}.player-item.local {background: rgba(0, 255, 255, 0.2);border: 1px solid #00ffff;
}.player-name {font-weight: bold;
}.player-ping {color: #ccc;font-size: 10px;
}.control-panel {position: absolute;top: 20px;right: 20px;width: 250px;background: rgba(0, 0, 0, 0.8);border-radius: 8px;padding: 15px;color: white;backdrop-filter: blur(10px);
}.panel-section {margin-bottom: 15px;
}.panel-section h4 {margin: 0 0 10px 0;color: #00ff88;font-size: 14px;
}.connection-controls {display: flex;flex-direction: column;gap: 8px;
}.server-input {padding: 8px;border: 1px solid #444;border-radius: 4px;background: #333;color: white;font-size: 12px;
}.connect-button, .disconnect-button {padding: 8px;border: none;border-radius: 4px;font-size: 12px;cursor: pointer;transition: background 0.3s;
}.connect-button {background: #00aa00;color: white;
}.connect-button:disabled {background: #666;cursor: not-allowed;
}.disconnect-button {background: #aa0000;color: white;
}.disconnect-button:disabled {background: #666;cursor: not-allowed;
}.sync-settings {display: flex;flex-direction: column;gap: 8px;
}.setting-item {display: flex;align-items: center;gap: 8px;font-size: 12px;cursor: pointer;
}.network-stats {display: flex;flex-direction: column;gap: 5px;
}.stat-item {font-size: 11px;color: #ccc;
}.chat-panel {position: absolute;bottom: 20px;left: 20px;width: 300px;background: rgba(0, 0, 0, 0.8);border-radius: 8px;backdrop-filter: blur(10px);border: 1px solid rgba(255, 255, 255, 0.1);
}.chat-messages {height: 200px;overflow-y: auto;padding: 10px;
}.chat-message {margin-bottom: 8px;padding: 5px 8px;border-radius: 4px;font-size: 12px;
}.chat-message.local {background: rgba(0, 255, 255, 0.2);
}.chat-message.remote {background: rgba(255, 255, 255, 0.1);
}.message-sender {font-weight: bold;color: #00ffff;
}.message-content {color: white;margin: 0 5px;
}.message-time {color: #ccc;font-size: 10px;
}.chat-input-container {display: flex;padding: 10px;border-top: 1px solid rgba(255, 255, 255, 0.1);
}.chat-input {flex: 1;padding: 8px;border: 1px solid #444;border-radius: 4px;background: #333;color: white;font-size: 12px;
}.send-button {margin-left: 8px;padding: 8px 12px;border: none;border-radius: 4px;background: #00aa00;color: white;cursor: pointer;font-size: 12px;
}.loading-overlay {position: absolute;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0, 0, 0, 0.8);display: flex;justify-content: center