Home > OS >  How to make sure I click the correct rect after the canvas has been zoomed and translated?
How to make sure I click the correct rect after the canvas has been zoomed and translated?

Time:11-02

The context

I'm creating a very simple game with html canvas (inside a next.js app). In this canvas I draw a few rectangles with some gap in between. If you know coloring pixels the game I bascially recreating this. This is what it looks like so far:

enter image description here

I also implemented a zoom feature and drag feature. The logic for it I found on google. But here is how the update function looks like:

update = (ctx) => {
    this.canvas.width = window.innerWidth;
    this.canvas.height = window.innerHeight;

    ctx.translate(window.innerWidth / 2, window.innerHeight / 2);
    ctx.scale(this.cameraZoom, this.cameraZoom);
    ctx.translate(
      -window.innerWidth / 2   this.cameraOffset.x,
      -window.innerHeight / 2   this.cameraOffset.y
    );
    ctx.clearRect(0, 0, window.width, window.height);
    this.drawTiles(ctx);
  };

this.cameraZoom just changes on mousewheel and the this.cameraOffset changes on mousemove and starts with cameraOffset = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; . This works fine, I can zoom in and drag the canvas around. The problem I have is when I click the squares - the wrong rectangles get filled after dragging

The Problem

Before the zoom and drag feature I implemented a logic, so I can click on the canvas and the correct rectangle will be filled with a color. That works fine. Then I implemented zooming, and I adjusted that logic until I made it work. But now I have to readjust it further because after dragging the "coordinates" aren't correct anymore, but I can't figure out a correct formula. Here is what I have so far (this function sits on every tile or rect I draw:

setPaintingColor = (game) => {
   
    const minX = (this.x - game.gap) * game.cameraZoom;
    const maxX = (this.x   this.width - game.gap) * game.cameraZoom;

    const minY = (this.y - game.gap) * game.cameraZoom;
    const maxY = (this.y   this.height - game.gap) * game.cameraZoom;

    if (!game.mouse.down) {
      return;
    }

    if (
      game.mouse.x >= minX &&
      game.mouse.x <= maxX &&
      game.mouse.y >= minY &&
      game.mouse.y <= maxY
    ) {
      this.color = "yellow";
    }
  };

So this sets the fill color of the rect and then the game update loop constantly calls draw of each rect (which fills the rect with the set color this.color.

The question

So now I want to find out a logic that works after dragging and zooming. When I drag the canvas and then try to click rects, nothing get painted.

I played around with the this.cameraOffset.x y values adding it, subtracting it etc, the closest I got was, when i clicked a tile, the tile to the left got painted but it wasn't logical to me at all.

So what I also tried is I calculate the distance I have moved after dragging (I set this.dragStart and this.dragEnd at the game object. Then adding it to the setPaintingColor function like that:

const distanceMovedX = game.dragStart.x - game.dragEnd.x;
    const distanceMovedY = game.dragStart.y - game.dragEnd.y;
    const minX = (this.x - game.gap   distanceMovedX) * game.cameraZoom;
    const maxX =
      (this.x   this.width - game.gap   distanceMovedX) * game.cameraZoom;

    const minY = (this.y - game.gap   distanceMovedY) * game.cameraZoom;
    const maxY =
      (this.y   this.height - game.gap   distanceMovedY) * game.cameraZoom;

Now when I drag and then click rects, about 6 - 8 tiles to the right where i clicked the rects get painted.

Does anybody know which logic I need to make it work again?

Thank you very much!

CodePudding user response:

I solved the problem.

The game object has a translateX and translateY property. That is calculated after the canvas have been moved and also some other calculation with screen width etc.

This is the logic that works now:

setPaintingColor = (game) => {
    if (game.wantsToDrag) {
      return;
    }

    const minX = (this.x - game.gap   game.translateX) * game.cameraZoom;
    const maxX =
      (this.x   this.width - game.gap   game.translateX) * game.cameraZoom;

    const minY = (this.y - game.gap   game.translateY) * game.cameraZoom;
    const maxY =
      (this.y   this.height - game.gap   game.translateY) * game.cameraZoom;

    if (!game.mouse.down) {
      return;
    }

    if (
      game.mouse.x >= minX &&
      game.mouse.x <= maxX &&
      game.mouse.y >= minY &&
      game.mouse.y <= maxY
    ) {
      this.color = "yellow";
    }
  };
}

So basically I added game.translateX and game.translateY to the calculation and then multiplying it with the camera zoom.

Now everything works as expected.

CodePudding user response:

To get from a pixel to a context coordinate, you can use getTransform like so:

const x = e.clientX - cvs.offsetLeft;
const y = e.clientY - cvs.offsetTop;
const t = ctx.getTransform();
  
const dx = t.e;
const dy = t.f;
const zx = t.a;
const zy = t.d;
  
const xt = (x - dx) / zx;
const yt = (y - dy) / zy;

Here's a working snippet:

const cvs = document.querySelector("canvas");
const ctx = cvs.getContext("2d");
const zoomSlider = document.querySelector("[data-slide=zoom]");
const xSlider = document.querySelector("[data-slide=panx]");
const ySlider = document.querySelector("[data-slide=pany]");

const S = 8;
const grid = Array.from({ length: 256 / S }, () => Array(256 / S).fill(0));
const drawGrid = () => {
  grid.forEach((row, r) => {
    row.forEach((cell, c) => {
      ctx.fillStyle = cell ? "blue": "white";
      ctx.fillRect(
        c * 8   1,
        r * 8   1,
        S - 2,
        S - 2
      );
    });
  });
}

const setTransform = () => {
  const dx = xSlider.valueAsNumber;
  const dy = ySlider.valueAsNumber;
  const zoom = zoomSlider.valueAsNumber;
  ctx.translate(dx, dy);
  ctx.scale(zoom, zoom);
}

const update = () => {
  ctx.resetTransform();
  ctx.clearRect(0, 0, cvs.width, cvs.height);
  setTransform();
  drawGrid();
}

// Interactivity
[zoomSlider, xSlider, ySlider].forEach(el => el.addEventListener("input", update));
cvs.addEventListener("click", e => {
  const x = e.clientX - cvs.offsetLeft;
  const y = e.clientY - cvs.offsetTop;
  const t = ctx.getTransform();
  
  const dx = t.e;
  const dy = t.f;
  const zx = t.a;
  const zy = t.d;
  
  const xt = (x - dx) / zx;
  const yt = (y - dy) / zy;

  // Color the right cell
  const cx = Math.floor(xt / S);
  const cy = Math.floor(yt / S);
  grid[cy][cx] = 1;
  update();
});

update();
document.body.appendChild(cvs);
<label>
  Zoom: <input data-slide="zoom" type="range" min="0.5" max="2" value="1" step="0.1">
</label>
<br>
<label>
  Horizontal pan: <input data-slide="panx" value="0" type="range" min="-128" max="128" step="1">
</label>
<br>
<label>
  Vertical pan: <input data-slide="pany" value="0" type="range" min="-128" max="128" step="1">
</label>
<br>


<canvas width="256" height="256" style="border: 1px solid red; background: grey"></canvas>

  • Related