You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用HTML Canvas的.scale()方法导致碰撞检测同步异常

类Agario游戏缩放导致碰撞同步问题

说明

我制作了演示该问题的视频。

背景

我正在开发一款类Agario的游戏,玩家以圆形角色出生,通过食用小型圆形食物来变大。客户端基于HTML Canvas实现,服务端使用Node.js。

问题

玩家进食后,我用Canvas的.scale()方法缓慢缩小视图,避免玩家变大后占满屏幕无法观察环境。但随着玩家变大,碰撞检测出现异常:要么玩家覆盖食物却吃不掉,要么没接触就提前触发进食。而且问题和移动方向相关:向上/左移动时,即使食物和玩家重叠也吃不掉;向右/下移动时,未接触就判定吃到食物。我搞不清问题根源,也不知道该修改哪部分代码。

代码细节

已移除无关代码,服务端处理所有核心逻辑(生成食物、碰撞检测等),客户端每帧会将坐标发送至服务端。

Player类的draw方法

draw(viewport) {
    if (this.isLocal) { // 本地玩家向服务端发送坐标
        socket.emit('currentPosition', {
            x: this.x / zoom,
            y: this.y / zoom,
            radius: this.radius / zoom,
            speedMultiplier: this.speedMultiplier,
            vx: this.vx, // 是否要在服务端更新这些?
            vy: this.vy
        });
    }

    ctx.save();
    ctx.translate(-viewport.x, -viewport.y);

    // 缩放玩家
    ctx.scale(zoom, zoom);

    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fillStyle = this.color;
    ctx.fill();
    ctx.strokeStyle = this.strokeColor;
    ctx.lineWidth = 5;
    ctx.stroke();
    ctx.closePath();
    ctx.restore(); 
}

我原本以为ctx.scale(zoom, zoom)会把玩家的x/y坐标和半径乘以缩放因子,但实际上该方法仅影响视觉绘制效果,不会修改存储的原始坐标。

Food类的draw方法

draw(viewport) {
        ctx.save();
        ctx.translate(-viewport.x, -viewport.y);
        ctx.scale(zoom, zoom); // 缩放食物
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
        ctx.strokeStyle = this.strokeColor;
        ctx.lineWidth = 3.5;
        ctx.stroke();
        ctx.closePath();
        ctx.restore();
}

玩家每吃掉一个食物,视图缩放比例会降低:

socket.on('foodEaten', (data) => {
    const player = gamePlayers.get(data.playerId);
    if (player.id == localPlayer.id) {
        zoom *= 0.9999;
    }
…

即每吃一个食物,视图缩小为原来的0.9999倍。

玩家移动逻辑

鼠标移动时计算目标方向:

canvas.addEventListener('mousemove', (event) => {
    const rect = canvas.getBoundingClientRect(); 
    mouse.x = (event.clientX - rect.left) / zoom; 
    mouse.y = (event.clientY - rect.top) / zoom; 

    // 更新玩家目标位置
    localPlayer.targetX = (mouse.x + viewport.x / zoom);
    localPlayer.targetY = (mouse.y + viewport.y / zoom);

    const dx = (localPlayer.targetX - localPlayer.x) * zoom;
    const dy = (localPlayer.targetY - localPlayer.y) * zoom;
    const distance = Math.sqrt(dx * dx + dy * dy); 

    // 中间省略其他代码...

    const xMovingDirection = dx / distance;
    const yMovingDirection = dy / distance;

    localPlayer.movingDirection = { x: xMovingDirection, y: yMovingDirection};
});

服务端碰撞检测

客户端发送坐标后,服务端比对玩家和食物的碰撞:

socket.on('currentPosition', (data) => { // 获取玩家当前坐标并更新
    const player = room.players.get(socket.id);
    if (player) {
        player.x = data.x; // 更新玩家x坐标
        player.y = data.y; // 更新玩家y坐标

        room.foods.forEach((food, foodId) => { // 检查是否吃到食物
            if (checkCollision(player, food)) {
                player.radius += food.radius * normalRadiusIncreaseRate; // 增加玩家半径

                let newFood; // 生成新食物
                newFood = new Food(); // 创建新食物实例
                room.foods.set(newFood.id, newFood);

                // 通知玩家吃到食物
                io.to(room.roomId).emit('foodEaten', {
                    food: food,
                    playerId: player.id,
                    radius: player.radius,
                    newFood: newFood
                });

                room.foods.delete(food.id); // 删除被吃掉的食物
            }

        // 向其他玩家发送该玩家的数据
        socket.to(room.roomId).emit('updatePlayerTarget', {
            id: socket.id,
            x: player.x,
            y: player.y
            // radius: player.radius
        });                   
    }
});

核心疑问

为什么用Canvas的.scale()缩放后,玩家和食物的同步会随时间越来越差?


问题根源

你的核心问题是客户端渲染缩放与服务端逻辑坐标系不一致,加上多次缩放累积的浮点精度误差,导致视觉位置和服务端计算的位置逐渐脱节:

  1. 渲染缩放的误区:ctx.scale(zoom, zoom)仅修改Canvas绘制上下文的缩放比例,只会改变视觉大小,不会修改你存储的player.x/y、food.x/y等原始逻辑坐标——你误以为缩放会修改坐标,但实际上这些值始终处于原始坐标系中。
  2. 坐标转换的双向不一致:客户端发送坐标时用this.x / zoom转换,移动逻辑里又做(localPlayer.targetX - localPlayer.x) * zoom这类反向转换,多次浮点运算会累积误差;鼠标位置转换时的viewport处理与缩放结合不当,导致玩家实际移动坐标与视觉位置偏离。
  3. 方向相关偏差:正负方向的浮点误差累积不对称,加上缩放因子的错误使用,导致服务端收到的坐标和视觉上的玩家位置出现偏移,碰撞检测自然失效。

解决方案

1. 统一坐标系:服务端用世界坐标系,客户端缩放仅做视图变换

服务端始终使用统一世界坐标系,所有碰撞、位置计算都基于该坐标系,完全不关心客户端缩放;客户端的zoom仅控制视图缩放比例,player.x/y、food.x/y直接存储服务端发来的世界坐标,不做除法/乘法转换。

2. 重构渲染逻辑:调整变换顺序

Canvas变换顺序是反向生效的,正确的视图变换顺序应为先缩放再平移,避免缩放影响平移偏移量:

ctx.save();
// 先缩放视图,再平移到玩家视角中心
ctx.scale(zoom, zoom);
ctx.translate(-(viewport.x - canvas.width/(2*zoom)), -(viewport.y - canvas.height/(2*zoom)));

// 直接绘制世界坐标的玩家和食物
ctx.arc(player.x, player.y, player.radius, 0, Math.PI*2);
// ...其他绘制代码
ctx.restore();

3. 修正鼠标位置到世界坐标的转换

鼠标位置转世界坐标时,直接基于缩放后的视图计算,无需额外处理viewport:

canvas.addEventListener('mousemove', (event) => {
    const rect = canvas.getBoundingClientRect();
    // 先转Canvas内部坐标,再转世界坐标
    const canvasX = event.clientX - rect.left;
    const canvasY = event.clientY - rect.top;
    // 世界坐标 = (Canvas坐标 - 画布中心)/缩放 + 玩家世界坐标
    const worldX = (canvasX - canvas.width/2)/zoom + localPlayer.x;
    const worldY = (canvasY - canvas.height/2)/zoom + localPlayer.y;
    
    localPlayer.targetX = worldX;
    localPlayer.targetY = worldY;

    // 移动方向直接用世界坐标计算,无需乘以zoom
    const dx = localPlayer.targetX - localPlayer.x;
    const dy = localPlayer.targetY - localPlayer.y;
    const distance = Math.sqrt(dx*dx + dy*dy);
    if (distance > 0) {
        localPlayer.movingDirection = {x: dx/distance, y: dy/distance};
    }
});

4. 移除客户端坐标转换,直接发送世界坐标

客户端给服务端发坐标时,直接发送原始player.x/y,无需除以zoom:

socket.emit('currentPosition', {
    x: this.x,
    y: this.y,
    radius: this.radius,
    speedMultiplier: this.speedMultiplier,
    vx: this.vx,
    vy: this.vy
});

5. 调整缩放逻辑:基于玩家半径动态计算zoom

不要每次吃食物就乘以0.9999,应根据玩家当前半径计算合适的缩放比例,避免浮点误差累积:

// 让玩家始终占屏幕高度的1/10
const targetPlayerScreenSize = canvas.height / 10;
zoom = targetPlayerScreenSize / localPlayer.radius;

内容的提问来源于stack exchange,提问作者Carson D

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 16:05:56