普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月26日首页

Canvas实现协同电影选座

2025年10月26日 19:40

技术栈

React + Canvas + Socket.IO + Express

效果图

image.png

架构设计

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

核心功能实现

1. Canvas渲染

设备像素比适配

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

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]);

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

座位布局

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

// 座位配置常量
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();
};

多状态定义

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

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

2. 实时协作

WebSocket通信

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

客户端连接管理:

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();
}, []);

服务端事件处理:

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 });
  });
});

用户会话管理

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

const [userId] = useState(() => Math.random().toString(36).substr(2, 9));

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

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. 交互

像素级点击检测

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

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;
      }
    }
  }
};

实时悬停效果

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

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

// 获取座位数据
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进行客户端状态管理:

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);
};
❌
❌