Canvas实现协同电影选座

技术栈

React + Canvas + Socket.IO + Express

效果图

架构设计

arduino 复制代码
┌─────────────────┐    WebSocket    ┌─────────────────┐
│   React Client  │ ←──────────────→ │  Express Server │
│                 │                 │                 │
│ ┌─────────────┐ │                 │ ┌─────────────┐ │
│ │   Canvas    │ │                 │ │  Socket.IO  │ │
│ │  Renderer   │ │                 │ │   Handler   │ │
│ └─────────────┘ │                 │ └─────────────┘ │
│                 │                 │                 │
│ ┌─────────────┐ │                 │ ┌─────────────┐ │
│ │ Socket.IO   │ │                 │ │ Seat Data   │ │
│ │   Client    │ │                 │ │  Manager    │ │
│ └─────────────┘ │                 │ └─────────────┘ │
└─────────────────┘                 └─────────────────┘

核心功能实现

1. Canvas渲染

设备像素比适配

现代设备的高分辨率屏幕(如Retina显示器)会导致Canvas绘制的图像显得模糊。我们通过适配设备像素比来解决这个问题:

javascript 复制代码
const drawSeatMap = useCallback(() => {
  if (!seatData || !canvasRef.current) return;

  const canvas = canvasRef.current;
  const ctx = canvas.getContext('2d');
  
  // 获取设备像素比
  const dpr = window.devicePixelRatio || 1;
  
  // 获取Canvas的显示尺寸
  const rect = canvas.getBoundingClientRect();
  const displayWidth = rect.width;
  const displayHeight = rect.height;
  
  // 设置Canvas的实际像素尺寸
  canvas.width = displayWidth * dpr;
  canvas.height = displayHeight * dpr;
  
  // 缩放绘图上下文以匹配设备像素比
  ctx.scale(dpr, dpr);
  
  // 设置Canvas的CSS尺寸
  canvas.style.width = displayWidth + 'px';
  canvas.style.height = displayHeight + 'px';
}, [seatData, hoveredSeat, userId]);

这种处理方式确保了在各种设备上都能获得清晰的显示效果。

座位布局

采用了符合真实影院布局的设计:

javascript 复制代码
// 座位配置常量
const SEAT_SIZE = 30;        // 座位大小
const SEAT_SPACING = 35;     // 座位间距
const ROW_SPACING = 40;      // 行间距
const CANVAS_PADDING = 50;   // 画布边距
const AISLE_WIDTH = 20;      // 过道宽度

// 绘制单个座位
const drawSeat = (ctx, row, seat, seatInfo, seatId) => {
  // 计算座位位置,第6座后添加过道
  const x = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
  const y = CANVAS_PADDING + 60 + row * ROW_SPACING;

  // 根据座位状态确定颜色
  let color = SEAT_COLORS.available;
  if (seatInfo.status === 'occupied') {
    color = SEAT_COLORS.occupied;
  } else if (seatInfo.status === 'selected') {
    color = seatInfo.selectedBy === userId ? 
      SEAT_COLORS.selected : SEAT_COLORS.selectedByOther;
  } else if (hoveredSeat === seatId) {
    color = SEAT_COLORS.hover;
  }

  // 绘制圆角矩形座位
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.roundRect(x, y, SEAT_SIZE, SEAT_SIZE, 5);
  ctx.fill();
};

多状态定义

定义了五种座位状态,每种状态都有独特的视觉表现:

javascript 复制代码
const SEAT_COLORS = {
  available: '#4CAF50',      // 可选 - 绿色
  selected: '#2196F3',       // 已选 - 蓝色
  occupied: '#F44336',       // 已售 - 红色
  selectedByOther: '#FF9800', // 他人已选 - 橙色
  hover: '#81C784'           // 悬停 - 浅绿色
};

2. 实时协作

WebSocket通信

使用Socket.IO实现双向实时通信:

客户端连接管理:

javascript 复制代码
useEffect(() => {
  const newSocket = io('http://localhost:3001');
  setSocket(newSocket);

  // 接收初始座位数据
  newSocket.on('seatData', (data) => {
    setSeatData(data);
  });

  // 监听座位状态更新
  newSocket.on('seatUpdated', ({ seatId, seat }) => {
    setSeatData(prev => ({
      ...prev,
      seats: {
        ...prev.seats,
        [seatId]: seat
      }
    }));
  });

  return () => newSocket.close();
}, []);

服务端事件处理:

javascript 复制代码
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  
  // 发送当前座位数据
  socket.emit('seatData', seatData);
  
  // 处理座位选择
  socket.on('selectSeat', (data) => {
    const { seatId, userId } = data;
    const seat = seatData.seats[seatId];
    
    // 业务逻辑验证
    if (seat.status === 'occupied') {
      socket.emit('error', { message: 'Seat is already occupied' });
      return;
    }
    
    // 切换选择状态
    if (seat.status === 'selected' && seat.selectedBy === userId) {
      seat.status = 'available';
      seat.selectedBy = null;
    } else {
      seat.status = 'selected';
      seat.selectedBy = userId;
    }
    
    // 广播给所有客户端
    io.emit('seatUpdated', { seatId, seat });
  });
});

用户会话管理

每个用户获得唯一标识符,确保座位选择的准确归属:

javascript 复制代码
const [userId] = useState(() => Math.random().toString(36).substr(2, 9));

当用户断开连接时,系统自动清理其选择的座位:

javascript 复制代码
socket.on('disconnect', () => {
  console.log('User disconnected:', socket.id);
  
  // 清除该用户选择的座位
  Object.keys(seatData.seats).forEach(seatId => {
    const seat = seatData.seats[seatId];
    if (seat.status === 'selected' && seat.selectedBy === socket.id) {
      seat.status = 'available';
      seat.selectedBy = null;
      io.emit('seatUpdated', { seatId, seat });
    }
  });
});

3. 交互

像素级点击检测

实现了精确的鼠标事件处理,支持像素级的点击检测:

javascript 复制代码
const handleCanvasClick = (event) => {
  if (!seatData || !socket) return;

  const canvas = canvasRef.current;
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  // 遍历所有座位进行碰撞检测
  for (let row = 0; row < seatData.rows; row++) {
    for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
      const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
      const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;

      if (x >= seatX && x <= seatX + SEAT_SIZE && 
          y >= seatY && y <= seatY + SEAT_SIZE) {
        const seatId = `${row}-${seat}`;
        const seatInfo = seatData.seats[seatId];

        if (seatInfo && seatInfo.status !== 'occupied') {
          socket.emit('selectSeat', { seatId, userId });
        }
        return;
      }
    }
  }
};

实时悬停效果

实现了流畅的鼠标悬停效果,提供即时的视觉反馈:

javascript 复制代码
const handleCanvasMouseMove = (event) => {
  if (!seatData) return;

  const canvas = canvasRef.current;
  const rect = canvas.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const y = event.clientY - rect.top;

  let foundSeat = null;

  // 查找鼠标悬停的座位
  for (let row = 0; row < seatData.rows; row++) {
    for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
      const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
      const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;

      if (x >= seatX && x <= seatX + SEAT_SIZE && 
          y >= seatY && y <= seatY + SEAT_SIZE) {
        foundSeat = `${row}-${seat}`;
        break;
      }
    }
    if (foundSeat) break;
  }

  if (foundSeat !== hoveredSeat) {
    setHoveredSeat(foundSeat);
  }
};

4. 数据管理与API

RESTful API

javascript 复制代码
// 获取座位数据
app.get('/api/seats', (req, res) => {
  res.json(seatData);
});

// 选择座位
app.post('/api/seats/select', (req, res) => {
  const { seatId, userId } = req.body;
  // 业务逻辑处理...
});

// 购买座位
app.post('/api/seats/book', (req, res) => {
  const { seatIds, userId } = req.body;
  
  const bookedSeats = [];
  const errors = [];
  
  seatIds.forEach(seatId => {
    const seat = seatData.seats[seatId];
    if (seat.status === 'selected' && seat.selectedBy === userId) {
      seat.status = 'occupied';
      seat.selectedBy = null;
      bookedSeats.push(seat);
    } else {
      errors.push(`Seat ${seatId} is not selected by you`);
    }
  });
  
  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }
  
  res.json({ success: true, bookedSeats });
});

状态管理策略

采用React Hooks进行客户端状态管理:

javascript 复制代码
const [seatData, setSeatData] = useState(null);
const [socket, setSocket] = useState(null);
const [hoveredSeat, setHoveredSeat] = useState(null);

// 获取选中座位信息
const getSelectedSeatsInfo = () => {
  if (!seatData) return [];
  
  return Object.values(seatData.seats)
    .filter(seat => seat.status === 'selected' && seat.selectedBy === userId)
    .sort((a, b) => a.row - b.row || a.seat - b.seat);
};
相关推荐
递归不收敛3 小时前
四、高效注意力机制与模型架构
人工智能·笔记·自然语言处理·架构
前端慢慢其修远4 小时前
fabric.js 中originX originY center设置问题
前端·fabric
im_AMBER4 小时前
React 04
前端·react.js·前端框架·1024程序员节
fhsWar5 小时前
Vue3 props: `required: true` 与 vant 的`makeRequiredProp`
前端·javascript·vue.js
泷羽Sec-静安5 小时前
Less-1 GET-Error based-Single quotes-String GET-基于错误-单引号-字符串
前端·css·网络·sql·安全·web安全·less
小时前端6 小时前
虚拟DOM已死?90%内存节省的Vapor模式正在颠覆前端
前端·html
Keepreal4966 小时前
Web Components简介及如何使用
前端·javascript·html
jump6806 小时前
TS中 unknown 和 any 的区别
前端
TG_yunshuguoji6 小时前
亚马逊云渠道商:如何通过配置自动替换构建故障自愈的云架构?
运维·服务器·架构·云计算·aws