web client
This commit is contained in:
216
Client/web/README.md
Normal file
216
Client/web/README.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 游戏方块容器 - 优化版本
|
||||
|
||||
## 🚀 项目概述
|
||||
|
||||
这是一个基于Vue 3和WebSocket的实时游戏监控系统,用于显示4个游戏方块的连接状态和动态圆点。
|
||||
|
||||
## ✨ 优化内容
|
||||
|
||||
### 1. **性能优化**
|
||||
- ✅ **WebSocket连接管理优化**:使用连接池和自动重连机制
|
||||
- ✅ **DOM操作优化**:使用Vue响应式数据替代直接DOM操作
|
||||
- ✅ **内存管理**:限制圆点数量,防止内存泄漏
|
||||
- ✅ **动画性能**:使用CSS3硬件加速和will-change属性
|
||||
|
||||
### 2. **代码结构优化**
|
||||
- ✅ **组件化设计**:将方块组件化,提高代码复用性
|
||||
- ✅ **服务类分离**:WebSocket逻辑独立到服务类
|
||||
- ✅ **配置管理**:统一的配置文件管理
|
||||
- ✅ **工具类**:通用工具函数和日志系统
|
||||
|
||||
### 3. **用户体验优化**
|
||||
- ✅ **响应式设计**:支持不同屏幕尺寸
|
||||
- ✅ **加载状态**:连接状态指示器和加载动画
|
||||
- ✅ **键盘快捷键**:快速操作功能
|
||||
- ✅ **状态栏**:实时显示连接状态和统计信息
|
||||
|
||||
### 4. **错误处理增强**
|
||||
- ✅ **完善的错误处理**:WebSocket连接错误处理
|
||||
- ✅ **自动重连机制**:指数退避算法
|
||||
- ✅ **日志系统**:分级日志记录
|
||||
- ✅ **性能监控**:性能测量和分析
|
||||
|
||||
### 5. **安全性优化**
|
||||
- ✅ **输入验证**:坐标范围验证
|
||||
- ✅ **连接验证**:WebSocket连接状态检查
|
||||
- ✅ **错误边界**:防止应用崩溃
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **前端框架**:Vue 3
|
||||
- **通信协议**:WebSocket
|
||||
- **样式**:CSS3 + 响应式设计
|
||||
- **协议缓冲**:Protobuf.js
|
||||
- **工具库**:自定义工具类
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
web/
|
||||
├── index.html # 主页面
|
||||
├── game.js # 主应用逻辑
|
||||
├── config.js # 配置文件
|
||||
├── utils.js # 工具类
|
||||
├── style.css # 样式文件
|
||||
├── proto/ # 协议文件
|
||||
│ ├── action.proto # 动作协议
|
||||
│ └── define.proto # 定义协议
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 启动项目
|
||||
```bash
|
||||
# 使用任何HTTP服务器启动项目
|
||||
python -m http.server 8000
|
||||
# 或
|
||||
npx serve .
|
||||
```
|
||||
|
||||
### 2. 访问应用
|
||||
打开浏览器访问 `http://localhost:8000`
|
||||
|
||||
## ⌨️ 键盘快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `H` | 隐藏/显示状态栏 |
|
||||
| `C` | 清除所有圆点 |
|
||||
| `R` | 重新连接所有WebSocket |
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### WebSocket配置
|
||||
```javascript
|
||||
websocket: {
|
||||
baseUrl: 'ws://localhost:8501', // WebSocket服务器地址
|
||||
reconnectAttempts: 5, // 重连次数
|
||||
reconnectDelay: 1000, // 重连延迟
|
||||
heartbeatInterval: 30000, // 心跳间隔
|
||||
connectionTimeout: 10000 // 连接超时
|
||||
}
|
||||
```
|
||||
|
||||
### 游戏配置
|
||||
```javascript
|
||||
game: {
|
||||
maxDotsPerSquare: 100, // 每个方块最大圆点数
|
||||
dotLifetime: 30000, // 圆点生命周期
|
||||
gridSize: { width: 400, height: 400 }, // 网格大小
|
||||
animationDuration: 300 // 动画持续时间
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 自定义配置
|
||||
|
||||
### 修改WebSocket服务器地址
|
||||
在 `config.js` 中修改 `websocket.baseUrl`:
|
||||
|
||||
```javascript
|
||||
websocket: {
|
||||
baseUrl: 'ws://your-server:port',
|
||||
// ... 其他配置
|
||||
}
|
||||
```
|
||||
|
||||
### 调整性能参数
|
||||
```javascript
|
||||
performance: {
|
||||
enableAnimations: true, // 启用动画
|
||||
enableHoverEffects: true, // 启用悬停效果
|
||||
enableBackdropFilter: true, // 启用背景模糊
|
||||
maxFPS: 60 // 最大帧率
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 性能特性
|
||||
|
||||
### 1. **响应式布局**
|
||||
- 桌面端:2x2网格布局
|
||||
- 平板端:2x2自适应布局
|
||||
- 移动端:1x4垂直布局
|
||||
|
||||
### 2. **动画优化**
|
||||
- CSS3硬件加速
|
||||
- 帧率控制
|
||||
- 动画性能监控
|
||||
|
||||
### 3. **内存管理**
|
||||
- 圆点数量限制
|
||||
- 自动清理机制
|
||||
- 内存泄漏防护
|
||||
|
||||
## 🐛 调试功能
|
||||
|
||||
### 日志系统
|
||||
```javascript
|
||||
// 在浏览器控制台查看详细日志
|
||||
utils.debug('调试信息');
|
||||
utils.info('一般信息');
|
||||
utils.warn('警告信息');
|
||||
utils.error('错误信息');
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
```javascript
|
||||
// 测量函数执行时间
|
||||
const { result, duration } = utils.measurePerformance('函数名', () => {
|
||||
// 要测量的代码
|
||||
});
|
||||
```
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
### 1. **输入验证**
|
||||
- 坐标范围检查
|
||||
- 数据类型验证
|
||||
- 恶意数据过滤
|
||||
|
||||
### 2. **连接安全**
|
||||
- WebSocket状态验证
|
||||
- 连接超时处理
|
||||
- 错误恢复机制
|
||||
|
||||
## 📱 移动端支持
|
||||
|
||||
- 响应式设计
|
||||
- 触摸友好的界面
|
||||
- 性能优化
|
||||
- 电池友好的动画
|
||||
|
||||
## 🎨 主题定制
|
||||
|
||||
### 颜色方案
|
||||
可以通过修改CSS变量来自定义主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary-color: #ff6b6b;
|
||||
--secondary-color: #4ecdc4;
|
||||
--background-color: #f5f5f5;
|
||||
--border-color: #555;
|
||||
}
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支
|
||||
3. 提交更改
|
||||
4. 推送到分支
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如有问题或建议,请提交 Issue 或联系开发团队。
|
||||
|
||||
---
|
||||
|
||||
**版本**: 2.0.0
|
||||
**最后更新**: 2024年
|
||||
**维护者**: 游戏开发团队
|
||||
71
Client/web/config.js
Normal file
71
Client/web/config.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// 游戏配置文件
|
||||
const GameConfig = {
|
||||
// WebSocket配置
|
||||
websocket: {
|
||||
baseUrl: 'ws://localhost:8501',
|
||||
reconnectAttempts: 5,
|
||||
reconnectDelay: 1000,
|
||||
heartbeatInterval: 30000, // 心跳间隔(毫秒)
|
||||
connectionTimeout: 10000, // 连接超时(毫秒)
|
||||
},
|
||||
|
||||
// 游戏配置
|
||||
game: {
|
||||
maxDotsPerSquare: 100, // 每个方块最大圆点数
|
||||
dotLifetime: 30000, // 圆点生命周期(毫秒)
|
||||
gridSize: {
|
||||
width: 400,
|
||||
height: 400
|
||||
},
|
||||
animationDuration: 300, // 动画持续时间(毫秒)
|
||||
},
|
||||
|
||||
// UI配置
|
||||
ui: {
|
||||
showStatusBar: true,
|
||||
showKeyboardHints: true,
|
||||
autoHideHints: true,
|
||||
hintsHideDelay: 5000, // 提示自动隐藏延迟(毫秒)
|
||||
},
|
||||
|
||||
// 性能配置
|
||||
performance: {
|
||||
enableAnimations: true,
|
||||
enableHoverEffects: true,
|
||||
enableBackdropFilter: true,
|
||||
maxFPS: 60,
|
||||
},
|
||||
|
||||
// 调试配置
|
||||
debug: {
|
||||
enableLogging: true,
|
||||
logLevel: 'info', // 'debug', 'info', 'warn', 'error'
|
||||
showConnectionDetails: false,
|
||||
}
|
||||
};
|
||||
|
||||
// 环境检测
|
||||
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
// 根据环境调整配置
|
||||
if (isDevelopment) {
|
||||
GameConfig.debug.enableLogging = true;
|
||||
GameConfig.debug.logLevel = 'debug';
|
||||
} else {
|
||||
GameConfig.debug.enableLogging = false;
|
||||
GameConfig.performance.enableAnimations = true;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
GameConfig.ui.showKeyboardHints = false;
|
||||
GameConfig.performance.enableHoverEffects = false;
|
||||
GameConfig.game.maxDotsPerSquare = 50; // 移动端减少圆点数量
|
||||
}
|
||||
|
||||
// 导出配置
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = GameConfig;
|
||||
} else {
|
||||
window.GameConfig = GameConfig;
|
||||
}
|
||||
@@ -1,11 +1,206 @@
|
||||
const { createApp } = Vue;
|
||||
|
||||
// WebSocket服务类
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
this.reconnectAttempts = new Map();
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000;
|
||||
}
|
||||
|
||||
connect(squareId, onMessage, onStatusChange) {
|
||||
const wsUrl = `ws://localhost:8501/?token=${squareId}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`WebSocket ${squareId} connected`);
|
||||
onStatusChange(true);
|
||||
this.reconnectAttempts.set(squareId, 0);
|
||||
|
||||
// 发送进入实例消息(protobuf)
|
||||
protoUtils.readyPromise.then(() => {
|
||||
const enterInstanceBuffer = protoUtils.createEnterInstanceMessage(1);
|
||||
if (enterInstanceBuffer) {
|
||||
this.sendMessage(ws, enterInstanceBuffer);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log(`WebSocket ${squareId} disconnected:`, e.code, e.reason);
|
||||
onStatusChange(false);
|
||||
this.connections.delete(squareId);
|
||||
|
||||
// 自动重连
|
||||
this.scheduleReconnect(squareId, onMessage, onStatusChange);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`WebSocket ${squareId} error:`, error);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
onMessage(event.data, squareId);
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.connections.set(squareId, ws);
|
||||
} catch (error) {
|
||||
console.error(`WebSocket ${squareId} init error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(ws, data) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
ws.send(data);
|
||||
} else {
|
||||
console.error('Attempted to send non-binary data through binary channel.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送动作消息
|
||||
sendAction(squareId, actionId, x, y) {
|
||||
const ws = this.connections.get(squareId);
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Cannot send action: WebSocket ${squareId} not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
protoUtils.readyPromise.then(() => {
|
||||
const actionBuffer = protoUtils.createActionMessage(actionId, x, y);
|
||||
if (actionBuffer) {
|
||||
this.sendMessage(ws, actionBuffer);
|
||||
console.log(`Action sent to square ${squareId}: actionId=${actionId}, x=${x}, y=${y}`);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 发送方向动作消息
|
||||
sendDirectionAction(squareId, directionBits) {
|
||||
const ws = this.connections.get(squareId);
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Cannot send direction action: WebSocket ${squareId} not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
protoUtils.readyPromise.then(() => {
|
||||
const actionBuffer = protoUtils.createDirectionActionMessage(directionBits);
|
||||
if (actionBuffer) {
|
||||
this.sendMessage(ws, actionBuffer);
|
||||
console.log(`Direction action sent to square ${squareId}: directionBits=${directionBits}`);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
scheduleReconnect(squareId, onMessage, onStatusChange) {
|
||||
const attempts = this.reconnectAttempts.get(squareId) || 0;
|
||||
|
||||
if (attempts < this.maxReconnectAttempts) {
|
||||
const delay = this.reconnectDelay * Math.pow(2, attempts);
|
||||
this.reconnectAttempts.set(squareId, attempts + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`Attempting to reconnect WebSocket ${squareId} (attempt ${attempts + 1})`);
|
||||
this.connect(squareId, onMessage, onStatusChange);
|
||||
}, delay);
|
||||
} else {
|
||||
console.error(`Max reconnection attempts reached for WebSocket ${squareId}`);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(squareId) {
|
||||
const ws = this.connections.get(squareId);
|
||||
if (ws) {
|
||||
ws.close();
|
||||
this.connections.delete(squareId);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectAll() {
|
||||
this.connections.forEach((ws, squareId) => {
|
||||
this.disconnect(squareId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 方块组件
|
||||
const SquareComponent = {
|
||||
props: ['squareId', 'connected', 'dots'],
|
||||
template: `
|
||||
<div class="square">
|
||||
<div class="connection-dot" :class="{ connected: connected }"></div>
|
||||
<div class="loading-indicator" v-if="!connected">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(dot, index) in dots"
|
||||
:key="dot.uid"
|
||||
class="game-dot"
|
||||
:class="{ 'player-controlled': dot.isPlayerControlled }"
|
||||
:style="{ left: dot.x + 'px', top: transformY(dot.y) + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
// 转换Y坐标,使左下角为原点
|
||||
transformY(y) {
|
||||
// 假设方块高度为400px(从GameConfig获取)
|
||||
const height = GameConfig.game.gridSize.height;
|
||||
// 将y坐标从"从下到上"转换为"从上到下"
|
||||
return height - y;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 主应用组件
|
||||
const App = {
|
||||
components: {
|
||||
'square-component': SquareComponent
|
||||
},
|
||||
template: `
|
||||
<div class="app-container">
|
||||
<div class="grid-container">
|
||||
<div class="square" v-for="(square, index) in squares" :key="index">
|
||||
<div class="connection-dot" :class="{ connected: square.connected }"></div>
|
||||
<square-component
|
||||
v-for="(square, index) in squares"
|
||||
:key="index"
|
||||
:square-id="index + 1"
|
||||
:connected="square.connected"
|
||||
:dots="square.dots"
|
||||
@mouseenter="setActiveSquare(index + 1)"
|
||||
@mouseleave="clearActiveSquare()"
|
||||
></square-component>
|
||||
</div>
|
||||
<div class="status-bar" v-if="showStatusBar">
|
||||
<div class="status-item">
|
||||
连接状态: {{ connectedCount }}/{{ squares.length }}
|
||||
</div>
|
||||
<div class="status-item">
|
||||
总圆点数: {{ totalDots }}
|
||||
</div>
|
||||
<div class="status-item" v-if="activeSquareId">
|
||||
当前方块: {{ activeSquareId }}
|
||||
</div>
|
||||
<div class="status-item" v-if="directionState.active">
|
||||
方向键: {{ directionStateText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,89 +208,310 @@ const App = {
|
||||
data() {
|
||||
return {
|
||||
squares: [
|
||||
{ connected: false, ws: null },
|
||||
{ connected: false, ws: null },
|
||||
{ connected: false, ws: null },
|
||||
{ connected: false, ws: null }
|
||||
]
|
||||
{ connected: false, dots: [] },
|
||||
{ connected: false, dots: [] },
|
||||
{ connected: false, dots: [] },
|
||||
{ connected: false, dots: [] }
|
||||
],
|
||||
wsService: null,
|
||||
showStatusBar: true,
|
||||
activeSquareId: null,
|
||||
directionState: {
|
||||
active: false,
|
||||
w: false, // 1
|
||||
s: false, // 2
|
||||
a: false, // 4
|
||||
d: false // 8
|
||||
},
|
||||
directionKeyCodes: {
|
||||
'KeyW': 'w',
|
||||
'KeyS': 's',
|
||||
'KeyA': 'a',
|
||||
'KeyD': 'd',
|
||||
// 兼容箭头键
|
||||
'ArrowUp': 'w',
|
||||
'ArrowDown': 's',
|
||||
'ArrowLeft': 'a',
|
||||
'ArrowRight': 'd'
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
connectedCount() {
|
||||
return this.squares.filter(square => square.connected).length;
|
||||
},
|
||||
totalDots() {
|
||||
return this.squares.reduce((total, square) => total + square.dots.length, 0);
|
||||
},
|
||||
// 计算方向状态文本
|
||||
directionStateText() {
|
||||
const keys = [];
|
||||
if (this.directionState.w) keys.push('W');
|
||||
if (this.directionState.s) keys.push('S');
|
||||
if (this.directionState.a) keys.push('A');
|
||||
if (this.directionState.d) keys.push('D');
|
||||
return keys.join('+') || '无';
|
||||
},
|
||||
// 计算方向位
|
||||
directionBits() {
|
||||
let bits = 0;
|
||||
if (this.directionState.w) bits |= 1; // W = 1
|
||||
if (this.directionState.s) bits |= 2; // S = 2
|
||||
if (this.directionState.a) bits |= 4; // A = 4
|
||||
if (this.directionState.d) bits |= 8; // D = 8
|
||||
return bits;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 为每个正方形建立独立的WebSocket连接
|
||||
this.squares.forEach((square, index) => {
|
||||
this.connectWebSocket(square, index);
|
||||
});
|
||||
this.wsService = new WebSocketService();
|
||||
this.initializeConnections();
|
||||
|
||||
// 添加键盘事件监听
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
window.addEventListener('keyup', this.handleKeyUp);
|
||||
|
||||
// 添加键盘快捷键
|
||||
this.addKeyboardShortcuts();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.wsService) {
|
||||
this.wsService.disconnectAll();
|
||||
}
|
||||
|
||||
// 移除键盘事件监听
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
window.removeEventListener('keyup', this.handleKeyUp);
|
||||
},
|
||||
methods: {
|
||||
connectWebSocket(square, index) {
|
||||
// 替换为实际的WebSocket服务器地址
|
||||
const wsUrl = `ws://localhost:8501/?token=${index + 1}`;
|
||||
initializeConnections() {
|
||||
this.squares.forEach((square, index) => {
|
||||
const squareId = index + 1;
|
||||
this.wsService.connect(
|
||||
squareId,
|
||||
this.handleMessage,
|
||||
(connected) => this.updateConnectionStatus(index, connected)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
updateConnectionStatus(index, connected) {
|
||||
this.squares[index].connected = connected;
|
||||
},
|
||||
|
||||
handleMessage(rawData, squareId) {
|
||||
const squareIndex = squareId - 1;
|
||||
|
||||
try {
|
||||
square.ws = new WebSocket(wsUrl);
|
||||
// 仅处理二进制 protobuf 消息
|
||||
if (rawData instanceof ArrayBuffer || rawData instanceof Uint8Array) {
|
||||
const arrayBuf = rawData instanceof Uint8Array ? rawData : new Uint8Array(rawData);
|
||||
const outerMsg = protoUtils.encoder.decodeMessage(arrayBuf);
|
||||
if (!outerMsg) return;
|
||||
|
||||
square.ws.onopen = () => {
|
||||
square.connected = true;
|
||||
console.log(`WebSocket ${index + 1} connected`);
|
||||
|
||||
// 连接建立后发送初始消息
|
||||
try {
|
||||
square.ws.send(JSON.stringify({
|
||||
type: "init"
|
||||
}));
|
||||
console.log(`Initial message sent to WebSocket ${index + 1}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send initial message to WebSocket ${index + 1}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
square.ws.onclose = (e) => {
|
||||
square.connected = false;
|
||||
console.log(`WebSocket ${index + 1} disconnected`);
|
||||
console.log(e.code, e.reason, e.wasClean)
|
||||
// 尝试重新连接
|
||||
// setTimeout(() => this.connectWebSocket(square, index), 1000);
|
||||
};
|
||||
|
||||
square.ws.onerror = (error) => {
|
||||
console.error(`WebSocket ${index + 1} error:`, error);
|
||||
};
|
||||
|
||||
square.ws.onmessage = (event) => {
|
||||
console.log(`WebSocket ${index + 1} message:`, event.data);
|
||||
// 处理接收到的消息
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const arr = JSON.parse(message.data);
|
||||
|
||||
if (Array.isArray(arr) && arr.length === 2) {
|
||||
const [x, y] = arr;
|
||||
console.log(`Creating dot at (${x}, ${y}) for square ${index}`);
|
||||
const msgId = outerMsg.ID;
|
||||
const msgEnum = protoUtils.encoder.MessageID;
|
||||
|
||||
// 根据消息ID处理不同类型的消息
|
||||
switch (msgId) {
|
||||
case msgEnum.MESSAGE_ID_POSITION: {
|
||||
// 处理位置更新消息
|
||||
const S2C_Position = protoUtils.encoder.root.lookupType('S2C_Position');
|
||||
const positionMsg = S2C_Position.decode(outerMsg.Payload);
|
||||
|
||||
const squareElement = document.querySelectorAll('.square')[index];
|
||||
if (!squareElement) {
|
||||
console.error('Square element not found');
|
||||
return;
|
||||
// 处理所有位置信息
|
||||
if (positionMsg.Info && positionMsg.Info.length > 0) {
|
||||
positionMsg.Info.forEach(info => {
|
||||
// 注意:坐标已经是以左下角为原点,保持原样
|
||||
if (utils.validateCoordinates(info.X, info.Y)) {
|
||||
// 查找是否已存在该UID的点
|
||||
const existingDotIndex = this.squares[squareIndex].dots.findIndex(dot => dot.uid === info.UID);
|
||||
|
||||
if (existingDotIndex !== -1) {
|
||||
// 已存在,更新位置
|
||||
this.squares[squareIndex].dots[existingDotIndex].x = info.X;
|
||||
this.squares[squareIndex].dots[existingDotIndex].y = info.Y;
|
||||
this.squares[squareIndex].dots[existingDotIndex].timestamp = Date.now();
|
||||
console.log(`Dot updated for square ${squareId}, UID ${info.UID}: (${info.X}, ${info.Y})`);
|
||||
} else {
|
||||
// 不存在,创建新点
|
||||
this.squares[squareIndex].dots.push({
|
||||
x: info.X,
|
||||
y: info.Y,
|
||||
timestamp: Date.now(),
|
||||
uid: info.UID,
|
||||
// 判断是否是玩家控制的点(假设UID为当前squareId的是玩家控制的)
|
||||
isPlayerControlled: info.UID === squareId
|
||||
});
|
||||
|
||||
// 限制每个方块的圆点数量,避免内存泄漏
|
||||
if (this.squares[squareIndex].dots.length > GameConfig.game.maxDotsPerSquare) {
|
||||
this.squares[squareIndex].dots.shift();
|
||||
}
|
||||
|
||||
console.log(`New dot added to square ${squareId} at (${info.X}, ${info.Y}) for UID ${info.UID}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 创建圆点元素
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'game-dot';
|
||||
dot.style.left = `${x}px`;
|
||||
dot.style.top = `${y}px`;
|
||||
dot.style.zIndex = '10';
|
||||
|
||||
// 添加到游戏场景
|
||||
squareElement.appendChild(dot);
|
||||
console.log('Dot added successfully');
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
|
||||
case msgEnum.MESSAGE_ID_ENTER_INSTANCE: {
|
||||
// 处理进入实例响应消息
|
||||
const S2C_EnterInstance = protoUtils.encoder.root.lookupType('S2C_EnterInstance');
|
||||
const enterInstanceMsg = S2C_EnterInstance.decode(outerMsg.Payload);
|
||||
|
||||
// 如果有位置信息,添加到对应方块
|
||||
if (enterInstanceMsg.Info) {
|
||||
const info = enterInstanceMsg.Info;
|
||||
// 注意:坐标已经是以左下角为原点,保持原样
|
||||
if (utils.validateCoordinates(info.X, info.Y)) {
|
||||
// 查找是否已存在该UID的点
|
||||
const existingDotIndex = this.squares[squareIndex].dots.findIndex(dot => dot.uid === info.UID);
|
||||
|
||||
if (existingDotIndex !== -1) {
|
||||
// 已存在,更新位置
|
||||
this.squares[squareIndex].dots[existingDotIndex].x = info.X;
|
||||
this.squares[squareIndex].dots[existingDotIndex].y = info.Y;
|
||||
this.squares[squareIndex].dots[existingDotIndex].timestamp = Date.now();
|
||||
this.squares[squareIndex].dots[existingDotIndex].isInitial = true;
|
||||
this.squares[squareIndex].dots[existingDotIndex].isPlayerControlled = info.UID === squareId;
|
||||
console.log(`Initial position updated for square ${squareId}, UID ${info.UID}: (${info.X}, ${info.Y})`);
|
||||
} else {
|
||||
// 不存在,创建新点
|
||||
this.squares[squareIndex].dots.push({
|
||||
x: info.X,
|
||||
y: info.Y,
|
||||
timestamp: Date.now(),
|
||||
uid: info.UID,
|
||||
isInitial: true, // 标记为初始位置
|
||||
// 判断是否是玩家控制的点(假设UID为当前squareId的是玩家控制的)
|
||||
isPlayerControlled: info.UID === squareId
|
||||
});
|
||||
|
||||
console.log(`Initial position created for square ${squareId}: (${info.X}, ${info.Y}) UID: ${info.UID}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unhandled message ID:', msgId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
return; // 已处理protobuf
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`WebSocket ${index + 1} init error:`, error);
|
||||
console.error('Error processing message:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 设置当前活动方块
|
||||
setActiveSquare(squareId) {
|
||||
if (this.squares[squareId - 1].connected) {
|
||||
this.activeSquareId = squareId;
|
||||
this.directionState.active = true;
|
||||
console.log(`Active square set to ${squareId}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 清除当前活动方块
|
||||
clearActiveSquare() {
|
||||
if (this.activeSquareId) {
|
||||
// 在清除之前发送一个id=0的action消息
|
||||
this.sendDirectionAction(this.activeSquareId, 0);
|
||||
|
||||
// 然后清除状态
|
||||
this.activeSquareId = null;
|
||||
this.directionState.active = false;
|
||||
// 重置所有方向键状态
|
||||
this.directionState.w = false;
|
||||
this.directionState.s = false;
|
||||
this.directionState.a = false;
|
||||
this.directionState.d = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 处理键盘按下事件
|
||||
handleKeyDown(event) {
|
||||
if (!this.activeSquareId) return;
|
||||
|
||||
const key = this.directionKeyCodes[event.code];
|
||||
if (key && this.directionState.hasOwnProperty(key)) {
|
||||
// 只有当状态真正变化时才发送
|
||||
if (!this.directionState[key]) {
|
||||
this.directionState[key] = true;
|
||||
// 计算新的方向位并发送
|
||||
const directionBits = this.directionBits;
|
||||
this.sendDirectionAction(this.activeSquareId, directionBits);
|
||||
}
|
||||
event.preventDefault(); // 防止页面滚动
|
||||
}
|
||||
},
|
||||
|
||||
// 处理键盘释放事件
|
||||
handleKeyUp(event) {
|
||||
if (!this.activeSquareId) return;
|
||||
|
||||
const key = this.directionKeyCodes[event.code];
|
||||
if (key && this.directionState.hasOwnProperty(key)) {
|
||||
// 只有当状态真正变化时才发送
|
||||
if (this.directionState[key]) {
|
||||
this.directionState[key] = false;
|
||||
// 计算新的方向位并发送
|
||||
const directionBits = this.directionBits;
|
||||
this.sendDirectionAction(this.activeSquareId, directionBits);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'h':
|
||||
this.showStatusBar = !this.showStatusBar;
|
||||
break;
|
||||
case 'c':
|
||||
this.clearAllDots();
|
||||
break;
|
||||
case 'r':
|
||||
this.reconnectAll();
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearAllDots() {
|
||||
this.squares.forEach(square => {
|
||||
square.dots = [];
|
||||
});
|
||||
console.log('All dots cleared');
|
||||
},
|
||||
|
||||
reconnectAll() {
|
||||
this.wsService.disconnectAll();
|
||||
setTimeout(() => {
|
||||
this.initializeConnections();
|
||||
}, 1000);
|
||||
console.log('Reconnecting all WebSockets...');
|
||||
},
|
||||
|
||||
// 发送动作消息到服务器
|
||||
sendAction(squareId, actionId, x, y) {
|
||||
if (this.wsService) {
|
||||
return this.wsService.sendAction(squareId, actionId, x, y);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// 发送方向动作消息到服务器
|
||||
sendDirectionAction(squareId, directionBits) {
|
||||
if (this.wsService) {
|
||||
return this.wsService.sendDirectionAction(squareId, directionBits);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,13 +4,31 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="游戏方块容器 - 实时WebSocket连接监控">
|
||||
<meta name="keywords" content="游戏,WebSocket,实时监控,Vue.js">
|
||||
<meta name="author" content="游戏开发团队">
|
||||
<title>游戏方块容器</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="lib/vue.global.js"></script>
|
||||
<script src="lib/protobuf.min.js"></script>
|
||||
<script src="config.js"></script>
|
||||
<script src="utils.js"></script>
|
||||
<script src="proto.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- 键盘快捷键提示 -->
|
||||
<div class="keyboard-hints">
|
||||
<h4>键盘快捷键</h4>
|
||||
<ul>
|
||||
<li><strong>H</strong> - 隐藏/显示状态栏</li>
|
||||
<li><strong>C</strong> - 清除所有圆点</li>
|
||||
<li><strong>R</strong> - 重新连接所有WebSocket</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script src="game.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
8
Client/web/lib/protobuf.min.js
vendored
Normal file
8
Client/web/lib/protobuf.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
18193
Client/web/lib/vue.global.js
Normal file
18193
Client/web/lib/vue.global.js
Normal file
File diff suppressed because it is too large
Load Diff
303
Client/web/proto.js
Normal file
303
Client/web/proto.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// Proto.js - 游戏协议处理
|
||||
// 基于 proto/action.proto 和 proto/define.proto 生成
|
||||
|
||||
// 协议定义(将由本地 .proto 文件动态填充枚举值)
|
||||
const GameProto = {};
|
||||
|
||||
// 协议编码器
|
||||
class ProtoEncoder {
|
||||
constructor() {
|
||||
this.protobuf = window.protobuf;
|
||||
this.root = null;
|
||||
this.readyPromise = this.initProtobuf();
|
||||
}
|
||||
|
||||
async initProtobuf() {
|
||||
try {
|
||||
// 使用本地 .proto 文件,避免硬编码
|
||||
const root = await this.protobuf.load([
|
||||
"proto/define.proto",
|
||||
"proto/action.proto"
|
||||
]);
|
||||
this.root = root;
|
||||
// 缓存枚举,供其他模块使用
|
||||
this.MessageID = this.root.lookupEnum('MessageID').values;
|
||||
this.ActionID = this.root.lookupEnum('ActionID').values;
|
||||
|
||||
// 更新全局GameProto枚举(避免硬编码)
|
||||
GameProto.MessageID = this.MessageID;
|
||||
GameProto.ActionID = this.ActionID;
|
||||
utils.info('Protobuf loaded from local files successfully');
|
||||
} catch (error) {
|
||||
utils.error('Failed to load protobuf files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 编码消息
|
||||
encodeMessage(messageId, payload) {
|
||||
try {
|
||||
if (!this.root) {
|
||||
throw new Error('Protobuf not initialized');
|
||||
}
|
||||
|
||||
const Message = this.root.lookupType('Message');
|
||||
const message = {
|
||||
ID: messageId,
|
||||
Payload: payload
|
||||
};
|
||||
|
||||
const buffer = Message.encode(message).finish();
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
utils.error('Failed to encode message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 编码动作
|
||||
encodeAction(actionId, payload) {
|
||||
try {
|
||||
if (!this.root) {
|
||||
throw new Error('Protobuf not initialized');
|
||||
}
|
||||
|
||||
const C2S_Action = this.root.lookupType('C2S_Action');
|
||||
const action = {
|
||||
Action: actionId,
|
||||
Payload: payload
|
||||
};
|
||||
|
||||
const buffer = C2S_Action.encode(action).finish();
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
utils.error('Failed to encode action:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码消息
|
||||
decodeMessage(buffer) {
|
||||
try {
|
||||
if (!this.root) {
|
||||
throw new Error('Protobuf not initialized');
|
||||
}
|
||||
|
||||
const Message = this.root.lookupType('Message');
|
||||
const message = Message.decode(buffer);
|
||||
return message;
|
||||
} catch (error) {
|
||||
utils.error('Failed to decode message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码动作
|
||||
decodeAction(buffer) {
|
||||
try {
|
||||
if (!this.root) {
|
||||
throw new Error('Protobuf not initialized');
|
||||
}
|
||||
|
||||
const C2S_Action = this.root.lookupType('C2S_Action');
|
||||
const action = C2S_Action.decode(buffer);
|
||||
return action;
|
||||
} catch (error) {
|
||||
utils.error('Failed to decode action:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 协议工具类
|
||||
class ProtoUtils {
|
||||
constructor() {
|
||||
this.encoder = new ProtoEncoder();
|
||||
this.readyPromise = this.encoder.readyPromise;
|
||||
}
|
||||
|
||||
// 创建移动指令
|
||||
createMoveAction(x, y) {
|
||||
const moveData = new Uint8Array([x, y]);
|
||||
return this.encoder.encodeAction(this.encoder.ActionID.ACTION_ID_MOVE, moveData);
|
||||
}
|
||||
|
||||
// 创建攻击指令
|
||||
createAttackAction(targetId) {
|
||||
const attackData = new Uint8Array([targetId]);
|
||||
return this.encoder.encodeAction(this.encoder.ActionID.ACTION_ID_ATTACK, attackData);
|
||||
}
|
||||
|
||||
// 创建消息
|
||||
createMessage(messageId, payload) {
|
||||
return this.encoder.encodeMessage(messageId, payload);
|
||||
}
|
||||
|
||||
// 解析接收到的数据
|
||||
parseReceivedData(data) {
|
||||
try {
|
||||
// 如果是字符串,尝试解析为JSON
|
||||
if (typeof data === 'string') {
|
||||
const jsonData = JSON.parse(data);
|
||||
return {
|
||||
type: 'json',
|
||||
data: jsonData
|
||||
};
|
||||
}
|
||||
|
||||
// 如果是二进制数据,尝试解析为protobuf
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
const message = this.encoder.decodeMessage(data);
|
||||
return {
|
||||
type: 'protobuf',
|
||||
data: message
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
data: data
|
||||
};
|
||||
} catch (error) {
|
||||
utils.error('Failed to parse received data:', error);
|
||||
return {
|
||||
type: 'error',
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 验证坐标数据
|
||||
validateCoordinateData(data) {
|
||||
if (!Array.isArray(data) || data.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [x, y] = data;
|
||||
return utils.validateCoordinates(x, y);
|
||||
}
|
||||
|
||||
// 格式化坐标数据
|
||||
formatCoordinateData(data) {
|
||||
if (this.validateCoordinateData(data)) {
|
||||
const [x, y] = data;
|
||||
return {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建进入实例消息(外层Message)
|
||||
createEnterInstanceMessage(instanceId = 0) {
|
||||
try {
|
||||
if (!this.encoder.root) {
|
||||
utils.warn('Protobuf root not ready');
|
||||
return null;
|
||||
}
|
||||
const C2S_EnterInstance = this.encoder.root.lookupType('C2S_EnterInstance');
|
||||
const enterInstance = {
|
||||
InstanceID: instanceId
|
||||
};
|
||||
const innerBuffer = C2S_EnterInstance.encode(enterInstance).finish();
|
||||
return this.encoder.encodeMessage(
|
||||
this.encoder.MessageID.MESSAGE_ID_ENTER_INSTANCE,
|
||||
innerBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
utils.error('Failed to create EnterInstance message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建动作消息(外层Message)
|
||||
createActionMessage(actionId, x, y) {
|
||||
try {
|
||||
if (!this.encoder.root) {
|
||||
utils.warn('Protobuf root not ready');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建移动指令的payload
|
||||
let payload;
|
||||
if (actionId === this.encoder.ActionID.ACTION_ID_MOVE) {
|
||||
// 创建包含x,y坐标的二进制数据
|
||||
// 注意:这里使用Float64Array来存储双精度浮点数,因为proto中X和Y是double类型
|
||||
const buffer = new ArrayBuffer(16); // 8字节 * 2 = 16字节
|
||||
const view = new Float64Array(buffer);
|
||||
view[0] = x;
|
||||
view[1] = y;
|
||||
payload = new Uint8Array(buffer);
|
||||
} else {
|
||||
// 其他类型的动作可以在这里添加
|
||||
utils.warn('Unsupported action ID:', actionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建C2S_Action消息
|
||||
const C2S_Action = this.encoder.root.lookupType('C2S_Action');
|
||||
const action = {
|
||||
Action: actionId,
|
||||
Payload: payload
|
||||
};
|
||||
const actionBuffer = C2S_Action.encode(action).finish();
|
||||
|
||||
// 封装到外层Message
|
||||
return this.encoder.encodeMessage(
|
||||
this.encoder.MessageID.MESSAGE_ID_ACTION,
|
||||
actionBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
utils.error('Failed to create Action message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建WASD方向动作消息(外层Message)
|
||||
createDirectionActionMessage(directionBits) {
|
||||
try {
|
||||
if (!this.encoder.root) {
|
||||
utils.warn('Protobuf root not ready');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建C2S_Action消息,payload为空
|
||||
const C2S_Action = this.encoder.root.lookupType('C2S_Action');
|
||||
const action = {
|
||||
Action: directionBits, // 使用位运算结果作为动作ID
|
||||
Payload: new Uint8Array(0) // 空payload
|
||||
};
|
||||
const actionBuffer = C2S_Action.encode(action).finish();
|
||||
|
||||
// 封装到外层Message
|
||||
return this.encoder.encodeMessage(
|
||||
this.encoder.MessageID.MESSAGE_ID_ACTION,
|
||||
actionBuffer
|
||||
);
|
||||
} catch (error) {
|
||||
utils.error('Failed to create Direction Action message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局协议工具实例
|
||||
const protoUtils = new ProtoUtils();
|
||||
|
||||
// 导出到全局
|
||||
window.GameProto = GameProto;
|
||||
window.ProtoEncoder = ProtoEncoder;
|
||||
window.ProtoUtils = ProtoUtils;
|
||||
window.protoUtils = protoUtils;
|
||||
|
||||
// 兼容性处理
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
GameProto,
|
||||
ProtoEncoder,
|
||||
ProtoUtils,
|
||||
protoUtils
|
||||
};
|
||||
}
|
||||
33
Client/web/proto/action.proto
Normal file
33
Client/web/proto/action.proto
Normal file
@@ -0,0 +1,33 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "common/proto/gen/cs";
|
||||
import "common.proto";
|
||||
|
||||
// MESSAGE_ID_ENTER_INSTANCE
|
||||
message C2S_EnterInstance {
|
||||
int32 InstanceID = 1;
|
||||
}
|
||||
message S2C_EnterInstance {
|
||||
PositionInfo Info = 1;
|
||||
}
|
||||
|
||||
// MESSAGE_ID_ACTION
|
||||
enum ActionID {
|
||||
ACTION_ID_INVALID = 0;
|
||||
ACTION_ID_MOVE = 1; // 1-15都是移动指令
|
||||
ACTION_ID_ATTACK = 16; // 攻击指令
|
||||
}
|
||||
message C2S_Action {
|
||||
ActionID Action = 1; // 指令ID
|
||||
bytes Payload = 2; // 指令数据
|
||||
}
|
||||
|
||||
// MESSAGE_ID_POSITION
|
||||
message PositionInfo {
|
||||
int32 UID = 1;
|
||||
double X = 2;
|
||||
double Y = 3;
|
||||
}
|
||||
message S2C_Position {
|
||||
repeated PositionInfo Info = 1;
|
||||
}
|
||||
6
Client/web/proto/common.proto
Normal file
6
Client/web/proto/common.proto
Normal file
@@ -0,0 +1,6 @@
|
||||
syntax = "proto3";
|
||||
|
||||
// 通用占位符文件,避免import错误
|
||||
message Placeholder {
|
||||
int32 id = 1;
|
||||
}
|
||||
16
Client/web/proto/define.proto
Normal file
16
Client/web/proto/define.proto
Normal file
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "common/proto/gen/cs";
|
||||
import "common.proto";
|
||||
|
||||
enum MessageID {
|
||||
MESSAGE_ID_INVALID = 0;
|
||||
MESSAGE_ID_ENTER_INSTANCE = 1; // 进入副本
|
||||
MESSAGE_ID_ACTION = 2; // 指令
|
||||
MESSAGE_ID_POSITION = 3; // 位置更新
|
||||
}
|
||||
|
||||
message Message {
|
||||
MessageID ID = 1;
|
||||
bytes Payload = 2;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ body, html {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* 应用容器填满整个视口并居中 */
|
||||
@@ -11,8 +12,10 @@ body, html {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 重置body样式 */
|
||||
@@ -22,25 +25,49 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 网格容器 - 固定2x2布局 */
|
||||
/* 网格容器 - 响应式布局 */
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 400px;
|
||||
grid-template-rows: 400px 400px;
|
||||
gap: 30px;
|
||||
padding: 40px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 400px));
|
||||
grid-template-rows: repeat(auto-fit, minmax(300px, 400px));
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background-color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
place-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.grid-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 正方形样式 */
|
||||
.square {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 300px;
|
||||
min-height: 300px;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(to right, #ddd 1px, transparent 1px),
|
||||
@@ -50,6 +77,13 @@ body {
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.square:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* 连接状态圆点 */
|
||||
@@ -61,20 +95,199 @@ body {
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4444; /* 默认断开状态-红色 */
|
||||
transition: background-color 0.3s;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.connection-dot.connected {
|
||||
background-color: #44ff44; /* 连接状态-绿色 */
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* 加载指示器 */
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 游戏动态圆点 */
|
||||
.game-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: red;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: linear-gradient(45deg, #ff6b6b, #ff8e8e);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
animation: dotAppear 0.3s ease-out;
|
||||
z-index: 5;
|
||||
/* 平滑移动效果 */
|
||||
transition: left 0.08s linear, top 0.08s linear;
|
||||
will-change: left, top, transform;
|
||||
/* 启用GPU加速 */
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* 标记为玩家控制的点 */
|
||||
.game-dot.player-controlled {
|
||||
background: linear-gradient(45deg, #4a90e2, #63b3ed);
|
||||
box-shadow: 0 0 10px rgba(74, 144, 226, 0.6);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
/* 为玩家控制的点设置更快的响应速度 */
|
||||
transition: left 0.06s linear, top 0.06s linear;
|
||||
}
|
||||
|
||||
@keyframes dotAppear {
|
||||
0% {
|
||||
transform: translate3d(-50%, -50%, 0) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(-50%, -50%, 0) scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-50%, -50%, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 25px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status-item::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #44ff44;
|
||||
}
|
||||
|
||||
/* 键盘快捷键提示 */
|
||||
.keyboard-hints {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.keyboard-hints:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.keyboard-hints h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.keyboard-hints ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.keyboard-hints li {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
/* 错误状态样式 */
|
||||
.square.error {
|
||||
border-color: #ff4444;
|
||||
animation: errorShake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
/* 性能优化 */
|
||||
.square {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.game-dot {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* 无障碍支持 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner,
|
||||
.game-dot,
|
||||
.connection-dot.connected {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.square:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
.square {
|
||||
border-width: 3px;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.game-dot {
|
||||
background: #000;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
}
|
||||
214
Client/web/utils.js
Normal file
214
Client/web/utils.js
Normal file
@@ -0,0 +1,214 @@
|
||||
// 工具类
|
||||
class Utils {
|
||||
constructor() {
|
||||
this.logLevels = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
};
|
||||
this.currentLogLevel = this.logLevels[GameConfig.debug.logLevel];
|
||||
}
|
||||
|
||||
// 日志记录
|
||||
log(level, message, ...args) {
|
||||
if (!GameConfig.debug.enableLogging) return;
|
||||
|
||||
const levelNum = this.logLevels[level];
|
||||
if (levelNum >= this.currentLogLevel) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(prefix, message, ...args);
|
||||
break;
|
||||
case 'info':
|
||||
console.info(prefix, message, ...args);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(prefix, message, ...args);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(prefix, message, ...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(message, ...args) {
|
||||
this.log('debug', message, ...args);
|
||||
}
|
||||
|
||||
info(message, ...args) {
|
||||
this.log('info', message, ...args);
|
||||
}
|
||||
|
||||
warn(message, ...args) {
|
||||
this.log('warn', message, ...args);
|
||||
}
|
||||
|
||||
error(message, ...args) {
|
||||
this.log('error', message, ...args);
|
||||
}
|
||||
|
||||
// 性能监控
|
||||
measurePerformance(name, fn) {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
const end = performance.now();
|
||||
const duration = end - start;
|
||||
|
||||
this.debug(`Performance [${name}]: ${duration.toFixed(2)}ms`);
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 坐标验证
|
||||
validateCoordinates(x, y, maxX = 400, maxY = 400) {
|
||||
// 坐标系以左下角为原点,x轴从左到右,y轴从下到上
|
||||
return x >= 0 && x <= maxX && y >= 0 && y <= maxY;
|
||||
}
|
||||
|
||||
// 随机颜色生成
|
||||
generateRandomColor() {
|
||||
const colors = [
|
||||
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4',
|
||||
'#feca57', '#ff9ff3', '#54a0ff', '#5f27cd'
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
formatTime(ms) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
// 格式化字节大小
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 深拷贝
|
||||
deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime());
|
||||
if (obj instanceof Array) return obj.map(item => this.deepClone(item));
|
||||
if (typeof obj === 'object') {
|
||||
const clonedObj = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = this.deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
}
|
||||
|
||||
// 本地存储工具
|
||||
storage = {
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
}
|
||||
},
|
||||
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.error('Failed to read from localStorage:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove from localStorage:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 网络状态检测
|
||||
checkNetworkStatus() {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
// 设备信息
|
||||
getDeviceInfo() {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
language: navigator.language,
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
onLine: navigator.onLine,
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
handleError(error, context = '') {
|
||||
this.error(`Error in ${context}:`, error);
|
||||
|
||||
// 可以在这里添加错误上报逻辑
|
||||
if (GameConfig.debug.showConnectionDetails) {
|
||||
console.group('Error Details');
|
||||
console.error('Error:', error);
|
||||
console.error('Context:', context);
|
||||
console.error('Stack:', error.stack);
|
||||
console.error('Device Info:', this.getDeviceInfo());
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局工具实例
|
||||
const utils = new Utils();
|
||||
|
||||
// 导出工具类
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Utils;
|
||||
} else {
|
||||
window.Utils = Utils;
|
||||
window.utils = utils;
|
||||
}
|
||||
Reference in New Issue
Block a user