2D Canvas | Smooth camera without jitter
Asked Answered
K

3

7

In my demo, you can see that the player's camera catches up with the player's position with a slight delay. It follows him until the player finally stops, gradually reducing the distance.

Unfortunately, I noticed that using this camera causes the pixels to be displayed inaccurately and either blur or wobble back and forth. This is especially noticeable on larger screens if you minimize the screen height.

If you simply return the target position within handleCamera, the functionality of the camera disappears, of course, but this solves the problem.

I am looking for a solution to keep my camera, but to remove the inaccurate pixels. I have already tried to include a certain tolerance, but this worked rather poorly. Any ideas, tips or experience as to why this phenomenon occurs?

const game = document.querySelector('#game');
const context = game.getContext('2d');

const background = new Image();
background.src = 'https://i.imgur.com/Ti1uecQ.png';

const player = {
    down: new Image(),
    up: new Image(),
    left: new Image(),
    right: new Image(),
    currentDirection: 'down',
    frame: 0,
    maxFrames: 4,
    idle: true,
};

player.down.src = 'https://i.imgur.com/cx6ag4V.png';
player.up.src = 'https://i.imgur.com/oZNeLGC.png';
player.left.src = 'https://i.imgur.com/yU2GBiF.png';
player.right.src = 'https://i.imgur.com/F69aMwq.png';

const backgroundHeight = 480;
const backgroundWidth = 840;
const playerHeight = 16;
const playerWidth = 12;
const initialGameHeight = playerHeight * 10;
const fps = 60;
const speed = 64;
const dodgeSpeed = speed * 2;
const cameraSpeed = speed / 8;
const step = 1 / fps;
const keymap = [];
const keyAssignments = {
    up: ['ArrowUp', 'w'],
    left: ['ArrowLeft', 'a'],
    right: ['ArrowRight', 'd'],
    down: ['ArrowDown', 's'],
    dodge: [' ']
};
const dodgeSteps = 20;
const dodgeDelaySteps = 40;
let dodgeStep = 0;
let dodgeDelayStep = 0;
let x = backgroundWidth / 2; // Player Starting X
let y = backgroundHeight / 2; // Player Starting Y
let cameraX = x;
let cameraY = y;
let previousMs = 0;
let scale = 1;
let keyListenerPaused = false;
let lastTempChange = null;

const handleScreens = () => {
    game.height = Math.round(window.innerHeight / 2) * 2;
    game.width = Math.round(window.innerWidth / 2) * 2;

    scale = Math.round(window.innerHeight / initialGameHeight);
    document.documentElement.style.setProperty('--scale', scale);
};

const handleKey = (key) => {
    if (keyListenerPaused) return;
}

const handleCamera = (currentValue, destinationValue, delta) => {
    // return destinationValue;

    let currentCameraSpeed = cameraSpeed * delta;

    if (Math.abs(currentValue - destinationValue) < currentCameraSpeed) {
        return destinationValue;
    }

    return +parseFloat(currentValue * (1 - currentCameraSpeed) + destinationValue * currentCameraSpeed).toPrecision(15);
};

const handlePlayerMovement = (delta) => {
    let currentSpeed = speed;
    let tempChange = { x: 0, y: 0 };
    let dodgePressed = false;

    if (!keyListenerPaused && dodgeStep === 0) {
        player.idle = true;

        keymap.forEach(direction => {
            if (keyAssignments.right.includes(direction)) {
                tempChange.x = 1;
                player.currentDirection = 'right';
                player.idle = false;
            }

            if (keyAssignments.left.includes(direction)) {
                tempChange.x = -1;
                player.currentDirection = 'left';
                player.idle = false;
            }

            if (keyAssignments.up.includes(direction)) {
                tempChange.y = -1;
                player.currentDirection = 'up';
                player.idle = false;
            }

            if (keyAssignments.down.includes(direction)) {
                tempChange.y = 1;
                player.currentDirection = 'down';
                player.idle = false;
            }

            if (keyAssignments.dodge.includes(direction)) {
                dodgePressed = true;
            }
        });
    }

    if (dodgeStep > 0) {
        if (dodgeStep < dodgeSteps * delta * 5) {
            dodgeStep += delta;
            currentSpeed = dodgeSpeed;
            tempChange = lastTempChange;
            dodgeDelayStep = 0;
        } else {
            dodgeStep = 0;
            dodgeDelayStep += delta;
        }
    } else {
        if (dodgePressed && dodgeDelayStep === 0) {
            if (tempChange.x !== 0 || tempChange.y !== 0) {
                dodgeStep += delta;
                currentSpeed = dodgeSpeed;
                lastTempChange = tempChange;
                dodgeDelayStep = 0;
            }

            dodgeDelayStep+=delta;
        } else {
            if (dodgeDelayStep > 0) {
                if (dodgeDelayStep < dodgeDelaySteps * delta * 5) {
                    dodgeDelayStep += delta;
                } else {
                    dodgeDelayStep = 0;
                }
            }
        }
    }

    let angle = Math.atan2(tempChange.y, tempChange.x);

    if (tempChange.x !== 0) {
        x += Math.cos(angle) * currentSpeed * delta;
    }

    if (tempChange.y !== 0) {
        y += Math.sin(angle) * currentSpeed * delta;
    }

    x = +parseFloat(x).toPrecision(15);
    y = +parseFloat(y).toPrecision(15);

    cameraX = handleCamera(cameraX, x, delta);
    cameraY = handleCamera(cameraY, y, delta);
};

let savedDelta = 0;
const draw = (delta) => {
    context.imageSmoothingEnabled = false;
    context.clearRect(0, 0, game.width, game.height);
    context.save();
    context.scale(scale, scale);
    context.translate(-cameraX - (playerWidth / 2) + (game.width / 2 / scale), -cameraY - (playerHeight / 2) + (game.height / 2 / scale));
    context.drawImage(background, 0, 0, backgroundWidth, backgroundHeight);
    context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, x, y, playerWidth, playerHeight);
    context.restore();

    if (player.idle) {
        player.frame = 0;
        return;
    }

    if ((delta + savedDelta) * 1000 >= fps * 2) {
        player.frame++;
        savedDelta = 0;

        if (player.frame === player.maxFrames) {
            player.frame = 0;
        }
    }

    savedDelta += delta;
};

const main = (timestampMs) => {
    if (previousMs === 0) {
        previousMs = timestampMs;
    }

    const delta = +parseFloat((timestampMs - previousMs) / 1000).toPrecision(15);

    handlePlayerMovement(delta);
    draw(delta);

    previousMs = timestampMs;

    requestAnimationFrame(main);
};

window.addEventListener('keydown', event => {
    if (event.metaKey) {
        keymap.splice(0, keymap.length);
        return;
    }

    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }

    keymap.push(event.key);
    handleKey(event.key);
});

window.addEventListener('keyup', event => {
    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }
});

window.addEventListener("blur", _ => {
    keymap.splice(0, keymap.length);
});

window.addEventListener('resize', () => handleScreens());
handleScreens();

requestAnimationFrame(main);
* {
    margin: 0;
    padding: 0;
}

body {
    overflow: hidden;
    image-rendering: pixelated;
    height: 100vh;
}
<canvas id="game"></canvas>

Instructions: click on the background image for the app to get focus and move the character with keys "w", "a", "d", "s".

In this short video you can see the pixelbugs in the end of movement: https://imgur.com/a/AEZLtWA

Kimmy answered 24/7, 2024 at 12:47 Comment(6)
I don't experience the problem you describe when moving the character across the background. What browser are you using?Cornett
I experience it with every browser and on any device I've tested it. Especially when the character stops moving you will probably see it jiggling. I've tested severall devices like windows, mac and mobiles aswell as Chromium, Firefox and Safari.Kimmy
Have you tried just calculating the cameraSpeed without delta? if (Math.abs(currentValue - destinationValue) < cameraSpeed) this removes the delayed jitter when I tested...Wentzel
I've already tested it, yes. I tested it on several screens with 60hz to 240hz. In any way it always ends with a slight jitter in the end of movement. To remove delta seems not to work fluently.Kimmy
It's worth mentioning that the camera movement is frame rate dependent (with movement proportional to time delta * distance delta, it moves slower on higher refresh rates), rather than frame rate independent (proportional to something like 0.5^(time delta * distance delta), framerate doesn't change the camera speed). This probably isn't the intended behavior and doesn't really affect the underlying issue, but in any case it makes it harder to test. It's also worth mentioning that the character and background aren't pixel aligned, which may or may not be the aesthetic you're going for.Subsequence
My mistake, a frame rate independent implementation would looks something like new distance delta = distance delta * 0.5 ^ (time delta * camera speed), (where as the current frame rate dependent implementation is new distance delta = distance delta * (time delta * camera speed).Subsequence
S
1

Jittering

Your character seemingly jitters due to the drawn character and drawn background being offset by fractions of pixels. As the camera moves, the resulting rounding makes the background round in one direction (in relationship to the character) sometimes, and in another at other times.

Syncing the coordinate systems

To solve this issue, the background and character should not be offset by fractions of pixels (or they should share the same offset). To accomplish this, a few approaches come to mind:

  • You can increment the character's coordinates by only integer values- this limits the engine's capabilities so we'll just discard this one.
  • When drawing, you can round the character's coordinates to integer values
  • When drawing, you can offset the background coordinates by the same fractional offset as the character

Considering your constraints of wanting a smoothly scrolling background, we'll consider rounding the player's coordinates to the background.

Rounding the drawn character's coordinates

To round the character's coordinates, simply change:

  context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, x, y, playerWidth, playerHeight);

to:

  context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, Math.round(x), Math.round(y), playerWidth, playerHeight);

This makes the character move jerkily as for some frames, so instead we may want to only do this when the player is still:

  let px = player.idle ? Math.round(x) : x;
  let py = player.idle ? Math.round(y) : y;
  context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, px, py, playerWidth, playerHeight);

All together, it looks like this:

const game = document.querySelector('#game');
const context = game.getContext('2d');

const background = new Image();
background.src = 'https://i.imgur.com/Ti1uecQ.png';

const player = {
    down: new Image(),
    up: new Image(),
    left: new Image(),
    right: new Image(),
    currentDirection: 'down',
    frame: 0,
    maxFrames: 4,
    idle: true,
};

player.down.src = 'https://i.imgur.com/cx6ag4V.png';
player.up.src = 'https://i.imgur.com/oZNeLGC.png';
player.left.src = 'https://i.imgur.com/yU2GBiF.png';
player.right.src = 'https://i.imgur.com/F69aMwq.png';

const backgroundHeight = 480;
const backgroundWidth = 840;
const playerHeight = 16;
const playerWidth = 12;
const initialGameHeight = playerHeight * 10;
const fps = 60;
const speed = 64;
const dodgeSpeed = speed * 2;
const cameraSpeed = speed / 8;
const step = 1 / fps;
const keymap = [];
const keyAssignments = {
    up: ['ArrowUp', 'w'],
    left: ['ArrowLeft', 'a'],
    right: ['ArrowRight', 'd'],
    down: ['ArrowDown', 's'],
    dodge: [' ']
};
const dodgeSteps = 20;
const dodgeDelaySteps = 40;
let dodgeStep = 0;
let dodgeDelayStep = 0;
let x = backgroundWidth / 2; // Player Starting X
let y = backgroundHeight / 2; // Player Starting Y
let cameraX = x;
let cameraY = y;
let previousMs = 0;
let scale = 1;
let keyListenerPaused = false;
let lastTempChange = null;

const handleScreens = () => {
    game.height = Math.round(window.innerHeight / 2) * 2;
    game.width = Math.round(window.innerWidth / 2) * 2;

    scale = Math.round(window.innerHeight / initialGameHeight);
    document.documentElement.style.setProperty('--scale', scale);
};

const handleKey = (key) => {
    if (keyListenerPaused) return;
}

const handleCamera = (currentValue, destinationValue, delta) => {
    // return destinationValue;

    let currentCameraSpeed = cameraSpeed * delta;

    if (Math.abs(currentValue - destinationValue) < currentCameraSpeed) {
        return destinationValue;
    }

    return +parseFloat(currentValue * (1 - currentCameraSpeed) + destinationValue * currentCameraSpeed).toPrecision(15);
};

const handlePlayerMovement = (delta) => {
    let currentSpeed = speed;
    let tempChange = { x: 0, y: 0 };
    let dodgePressed = false;

    if (!keyListenerPaused && dodgeStep === 0) {
        player.idle = true;

        keymap.forEach(direction => {
            if (keyAssignments.right.includes(direction)) {
                tempChange.x = 1;
                player.currentDirection = 'right';
                player.idle = false;
            }

            if (keyAssignments.left.includes(direction)) {
                tempChange.x = -1;
                player.currentDirection = 'left';
                player.idle = false;
            }

            if (keyAssignments.up.includes(direction)) {
                tempChange.y = -1;
                player.currentDirection = 'up';
                player.idle = false;
            }

            if (keyAssignments.down.includes(direction)) {
                tempChange.y = 1;
                player.currentDirection = 'down';
                player.idle = false;
            }

            if (keyAssignments.dodge.includes(direction)) {
                dodgePressed = true;
            }
        });
    }

    if (dodgeStep > 0) {
        if (dodgeStep < dodgeSteps * delta * 5) {
            dodgeStep += delta;
            currentSpeed = dodgeSpeed;
            tempChange = lastTempChange;
            dodgeDelayStep = 0;
        } else {
            dodgeStep = 0;
            dodgeDelayStep += delta;
        }
    } else {
        if (dodgePressed && dodgeDelayStep === 0) {
            if (tempChange.x !== 0 || tempChange.y !== 0) {
                dodgeStep += delta;
                currentSpeed = dodgeSpeed;
                lastTempChange = tempChange;
                dodgeDelayStep = 0;
            }

            dodgeDelayStep+=delta;
        } else {
            if (dodgeDelayStep > 0) {
                if (dodgeDelayStep < dodgeDelaySteps * delta * 5) {
                    dodgeDelayStep += delta;
                } else {
                    dodgeDelayStep = 0;
                }
            }
        }
    }

    let angle = Math.atan2(tempChange.y, tempChange.x);

    if (tempChange.x !== 0) {
        x += Math.cos(angle) * currentSpeed * delta;
    }

    if (tempChange.y !== 0) {
        y += Math.sin(angle) * currentSpeed * delta;
    }

    x = +parseFloat(x).toPrecision(15);
    y = +parseFloat(y).toPrecision(15);

    cameraX = handleCamera(cameraX, x, delta);
    cameraY = handleCamera(cameraY, y, delta);
};

let savedDelta = 0;
const draw = (delta) => {
    context.imageSmoothingEnabled = false;
    context.clearRect(0, 0, game.width, game.height);
    context.save();
    context.scale(scale, scale);
    context.translate(-cameraX - (playerWidth / 2) + (game.width / 2 / scale), -cameraY - (playerHeight / 2) + (game.height / 2 / scale));
    context.drawImage(background, 0, 0, backgroundWidth, backgroundHeight);
    let px = player.idle ? Math.round(x) : x;
    let py = player.idle ? Math.round(y) : y;
    context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, px, py, playerWidth, playerHeight);
    context.restore();

    if (player.idle) {
        player.frame = 0;
        return;
    }

    if ((delta + savedDelta) * 1000 >= fps * 2) {
        player.frame++;
        savedDelta = 0;

        if (player.frame === player.maxFrames) {
            player.frame = 0;
        }
    }

    savedDelta += delta;
};

const main = (timestampMs) => {
    if (previousMs === 0) {
        previousMs = timestampMs;
    }

    const delta = +parseFloat((timestampMs - previousMs) / 1000).toPrecision(15);

    handlePlayerMovement(delta);
    draw(delta);

    previousMs = timestampMs;

    requestAnimationFrame(main);
};

window.addEventListener('keydown', event => {
    if (event.metaKey) {
        keymap.splice(0, keymap.length);
        return;
    }

    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }

    keymap.push(event.key);
    handleKey(event.key);
});

window.addEventListener('keyup', event => {
    let index = keymap.indexOf(event.key);

    if (index > -1) {
        keymap.splice(index, 1);
    }
});

window.addEventListener("blur", _ => {
    keymap.splice(0, keymap.length);
});

window.addEventListener('resize', () => handleScreens());
handleScreens();

requestAnimationFrame(main);
* {
    margin: 0;
    padding: 0;
}

body {
    overflow: hidden;
    image-rendering: pixelated;
    height: 100vh;
}
<canvas id="game"></canvas>
Subsequence answered 31/7, 2024 at 0:47 Comment(9)
Thank you Steve for the answer. I have now tested various options using your tips and unfortunately cannot find an optimal way to avoid my jittering. To be precise, I have the feeling that it even got worse, which is why I have unfortunately discarded my previous attempts at rounding. What I have already found out, however, is that the pixel errors are caused by the camera. Do you have any ideas on how I can apply your approaches to the camera alone or optimize it?Kimmy
I'm not sure what you mean with 'jittering' being worse, if you mean camera smoothness (which does seem to be worse), whether pixels are wobbling back and forth in opposite directions (which no longer happens for me), or something else. I also don't know what you mean by pixel errors being caused by the camera. Many issues probably stem from upscaling pixel art using transforms on the fly, and most can be mitigated by upscaling the assets ahead of time.Subsequence
When I round the pixels, it causes jumps to the player. Based on my high refreshrate of 240hz the jumps are way more visible because of the higher triggered roundings. Im not sure about your testing framerate but for me it causes several pixelbugs. Also I think, the movement at all is very fluently but my problem is the ending of the movement, where the pixels are not correctly positionioned because of the camera movement. I've added a video into the question to make the problem more obvious.Kimmy
Can you also reply with a video of the pixelbugs when you use my code? I'm testing on a low refresh rate screen, and I don't see the issue you're describing.Subsequence
Updated my answer with another possible solutionSubsequence
Surely I can provide a video with your updated code: imgur.com/a/m4xc4AV Within the video I used a 60hz monitor, macOS with chromium browser. The jitter is gone, which makes totally sense because of the rounding. The problem is now the background, which is forced to jump why I tried to not round at all. If you look closely , you can see the jumps or "shifts". You sure this approaches aren't creating other problems based on the fact that everything works fine without the camera active? I think maybe there is some mathematical problem because of the pixels on screen and the delta.Kimmy
Rounding the background location seems to introduce the problems you're describing. It's probably better to round player location on idle, to which I've updated my answer. I think most of your issues arise from the way that the browsers upscale low-res assets to draw pixel art, which pixel alignment can solve.Subsequence
I've testet your results with 60hz and 240hz. It seems like now it "jiggles" with the background and not against it. I've created two visuals (240hz) in desktop (imgur.com/a/Zf5PRVN) and mobile (imgur.com/a/5EoD4Sl). You may think it also has problems with the game size itself? Its always more visible on mobile. On desktop you really have to watch it to see. Please watch the characters "eye" here imgur.com/a/VTbWN5g to see pixelbugsKimmy
Actually it doesnt matter on which screen. Im currently mixing up the precision. As mentioned before if I remove the camera or set it same speed as the character, everywhere it looks good. There has to be some problem with the camera distance. If the precision is 4, it looks like its working but its way to fast.Kimmy
S
1

I'm a bit late but I think the issue you describe is mainly caused by the camera slowing down too much at the end of its movement when catching up the player position, this is caused by this line :

return +parseFloat(currentValue * (1 - currentCameraSpeed) + destinationValue * currentCameraSpeed).toPrecision(15);

which moves the camera position dynamically to get a smooth animation, but as currentValue get closer to destinationValue the camera movements appear irregular because we can actually perceive them (even if it's a matter of pixels).

One way to solve this is to adjust the camera position linearly at the end of its movement when the player is idle, so it never goes slower than a specific threshold :

const handleCamera = (currentValue, destinationValue, delta) => {
  let currentCameraSpeed = cameraSpeed * delta;

  const dx = currentValue - destinationValue;
  const dist = Math.abs(dx);

  if (dist < currentCameraSpeed) {
    return destinationValue;
  }

  if (player.idle && dist < 3) {
    // Adjust the camera position linearly at the end of its movement
    currentCameraSpeed *= 4;
    return currentValue - Math.sign(dx) * Math.min(dist, currentCameraSpeed);
  }

  return currentValue * (1 - currentCameraSpeed) + destinationValue * currentCameraSpeed;
};

One may think in this case we could just increase the value of currentCameraSpeed or cameraSpeed, but the end result wouldn't be the same as it will affect the overall camera movement, not just the end of it.

Note I removed the +parsefloat((...).toPrecision(15)) part, because I don't see how it can make things better (I suggest you remove them all from your code)


Also, in order to stabilize the camera you can floor the camera offsets to ensure they remain constant when the screen size doesn't change (ie. this prevents floating point rounding errors when adding these offsets to the camera position).

const camOffsetX = Math.floor(-playerWidth/2 + game.width/2/scale);
const camOffsetY = Math.floor(-playerHeight/2 + game.height/2/scale);
context.translate(-cameraX + camOffsetX, -cameraY + camOffsetY);

Here is the full code :

const game = document.querySelector('#game');
const context = game.getContext('2d');

const background = new Image();
background.src = 'https://i.imgur.com/Ti1uecQ.png';

const player = {
  down: new Image(),
  up: new Image(),
  left: new Image(),
  right: new Image(),
  currentDirection: 'down',
  frame: 0,
  maxFrames: 4,
  idle: true,
};

player.down.src = 'https://i.imgur.com/cx6ag4V.png';
player.up.src = 'https://i.imgur.com/oZNeLGC.png';
player.left.src = 'https://i.imgur.com/yU2GBiF.png';
player.right.src = 'https://i.imgur.com/F69aMwq.png';

const backgroundHeight = 480;
const backgroundWidth = 840;
const playerHeight = 16;
const playerWidth = 12;
const initialGameHeight = playerHeight * 10;
const fps = 60;
const speed = 64;
const dodgeSpeed = speed * 2;
const cameraSpeed = speed / 8;
const step = 1 / fps;
const keymap = [];
const keyAssignments = {
  up: ['ArrowUp', 'w'],
  left: ['ArrowLeft', 'a'],
  right: ['ArrowRight', 'd'],
  down: ['ArrowDown', 's'],
  dodge: [' ']
};
const dodgeSteps = 20;
const dodgeDelaySteps = 40;
let dodgeStep = 0;
let dodgeDelayStep = 0;
let x = backgroundWidth / 2; // Player Starting X
let y = backgroundHeight / 2; // Player Starting Y
let cameraX = x;
let cameraY = y;
let previousMs = 0;
let scale = 1;
let keyListenerPaused = false;
let lastTempChange = null;

const handleScreens = () => {
  game.height = Math.floor(window.innerHeight / 2) * 2;
  game.width = Math.floor(window.innerWidth / 2) * 2;

  scale = Math.round(window.innerHeight / initialGameHeight);
  document.documentElement.style.setProperty('--scale', scale);
};

const handleKey = (key) => {
  if (keyListenerPaused) return;
}

const handleCamera = (currentValue, destinationValue, delta) => {
  let currentCameraSpeed = cameraSpeed * delta;

  const dx = currentValue - destinationValue;
  const dist = Math.abs(dx);

  if (dist < currentCameraSpeed) {
    return destinationValue;
  }

  if (player.idle && dist < 3) {
    currentCameraSpeed *= 4;
    return currentValue - Math.sign(dx) * Math.min(dist, currentCameraSpeed);
  }

  return currentValue * (1 - currentCameraSpeed) + destinationValue * currentCameraSpeed;
};

const handlePlayerMovement = (delta) => {
  let currentSpeed = speed;
  let tempChange = { x: 0, y: 0 };
  let dodgePressed = false;

  if (!keyListenerPaused && dodgeStep === 0) {
    player.idle = true;

    keymap.forEach(direction => {
      if (keyAssignments.right.includes(direction)) {
        tempChange.x = 1;
        player.currentDirection = 'right';
        player.idle = false;
      }

      if (keyAssignments.left.includes(direction)) {
        tempChange.x = -1;
        player.currentDirection = 'left';
        player.idle = false;
      }

      if (keyAssignments.up.includes(direction)) {
        tempChange.y = -1;
        player.currentDirection = 'up';
        player.idle = false;
      }

      if (keyAssignments.down.includes(direction)) {
        tempChange.y = 1;
        player.currentDirection = 'down';
        player.idle = false;
      }

      if (keyAssignments.dodge.includes(direction)) {
        dodgePressed = true;
      }
    });
  }

  if (dodgeStep > 0) {
    if (dodgeStep < dodgeSteps * delta * 5) {
      dodgeStep += delta;
      currentSpeed = dodgeSpeed;
      tempChange = lastTempChange;
      dodgeDelayStep = 0;
    } else {
      dodgeStep = 0;
      dodgeDelayStep += delta;
    }
  } else {
    if (dodgePressed && dodgeDelayStep === 0) {
      if (tempChange.x !== 0 || tempChange.y !== 0) {
        dodgeStep += delta;
        currentSpeed = dodgeSpeed;
        lastTempChange = tempChange;
        dodgeDelayStep = 0;
      }

      dodgeDelayStep+=delta;
    } else {
      if (dodgeDelayStep > 0) {
        if (dodgeDelayStep < dodgeDelaySteps * delta * 5) {
          dodgeDelayStep += delta;
        } else {
          dodgeDelayStep = 0;
        }
      }
    }
  }

  let angle = Math.atan2(tempChange.y, tempChange.x);

  if (tempChange.x !== 0) {
    x += Math.cos(angle) * currentSpeed * delta;
  }

  if (tempChange.y !== 0) {
    y += Math.sin(angle) * currentSpeed * delta;
  }

  cameraX = handleCamera(cameraX, x, delta);
  cameraY = handleCamera(cameraY, y, delta);
};

let savedDelta = 0;
const draw = (delta) => {
  context.imageSmoothingEnabled = false;
  context.clearRect(0, 0, game.width, game.height);
  context.save();
  context.scale(scale, scale);

  const camOffsetX = Math.floor(-playerWidth/2 + game.width/2/scale);
  const camOffsetY = Math.floor(-playerHeight/2 + game.height/2/scale);
  context.translate(-cameraX + camOffsetX, -cameraY + camOffsetY);
  
  context.drawImage(background, 0, 0, backgroundWidth, backgroundHeight);
  context.drawImage(player[player.currentDirection], playerWidth * player.frame, 0, playerWidth, playerHeight, x, y, playerWidth, playerHeight);
  context.restore();

  if (player.idle) {
    player.frame = 0;
    return;
  }

  if ((delta + savedDelta) * 1000 >= fps * 2) {
    player.frame++;
    savedDelta = 0;

    if (player.frame === player.maxFrames) {
      player.frame = 0;
    }
  }

  savedDelta += delta;
};

const main = (timestampMs) => {
  if (previousMs === 0) {
    previousMs = timestampMs;
  }

  // const delta = +parseFloat((timestampMs - previousMs) / 1000).toPrecision(15);
  const delta = 1/fps;

  handlePlayerMovement(delta);
  draw(delta);

  previousMs = timestampMs;

  requestAnimationFrame(main);
};

window.addEventListener('keydown', event => {
  if (event.metaKey) {
    keymap.splice(0, keymap.length);
    return;
  }

  let index = keymap.indexOf(event.key);

  if (index > -1) {
    keymap.splice(index, 1);
  }

  keymap.push(event.key);
  handleKey(event.key);
});

window.addEventListener('keyup', event => {
  let index = keymap.indexOf(event.key);

  if (index > -1) {
    keymap.splice(index, 1);
  }
});

window.addEventListener("blur", _ => {
  keymap.splice(0, keymap.length);
});

window.addEventListener('resize', () => handleScreens());
handleScreens();

requestAnimationFrame(main);
* {
    margin: 0;
    padding: 0;
}

body {
    overflow: hidden;
    image-rendering: pixelated;
    height: 100vh;
}
<canvas id="game"></canvas>
Stalinsk answered 12/8, 2024 at 16:0 Comment(6)
This seems to be a working solution if I mix up some values. Your rounding on the camera doesnt seem to change something for me so I left it out. Your solution with the camera movement speed works good, until the screen reaches mobile devices. You have any mathematical approch to fix this too? I've created an example (imgur.com/a/NTcTR34) with my current camera code (imgur.com/a/nexoKGi). There is a possibillity where you mix up the "distance < 1" and the "currentCameraSpeed" for mobile only. But there has to be a math solution to get it in gerenell working.Kimmy
I can't reproduce the issue with this code, even on mobile device. On your side, does it happen on a specific device or screen size (which one) ? and does it seem to happen systematically (every time the player goes idle) or just sometimes randomly ? In the latter case it could be due to fps drops. I don't understand what you mean by "if I mix up some values".Stalinsk
Sorry for the misunderstanding. The problem is explicitly noticeable as soon as the scaling of the game is minimized. On desktop, the scaling is at 6 for me, mobile at 2. If the scaling decreases, the calculation probably becomes unclean. As you have already assumed, it occurs again when the player is standing and the camera approaches the position. I assume that the distance is too small for your calculation, which is why it cannot be displayed correctly.Kimmy
If you adjust the values in the variables for mobile so that, for example, "distance < 1" becomes "distance < 3" and "currentCameraSpeed *= 1" is set to "*= 4", the problem seems to be better managed because the camera moves through the small pieces faster. You will probably be able to reproduce the problem if you play around with your frequency rate.Kimmy
The scaling has (and should have) no impact on the calculations for the camera movements since it's applied on the whole canvas (ie. the pixels just look bigger or smaller in the end, but nothing more). I used the condition dist < k with k=3 set arbitrarily because it looks good, you can change the value of k of course but I don't think it should be relative to the scale, it should remain a constant. Also, the code already compensates for fps drops so that the perceived game speed remain the same, yet for big fps drops that are visible, I don't think there is much to do about that.Stalinsk
Based on your information, I have tested on different devices and sizes, as well as with different screens and frames. If I rebuild the handleCamera code (imgur.com/a/2GU0AKe), I get an acceptable result, as long as the scaling is at least 2. I also found that the bugs increased or decreased based on the frame rate and screen size. I'm still searching for the exact solution but your's helped me the most for now, thank you.Kimmy
A
0

Just round your camera position before using it for translation.

let cx = -cameraX - (playerWidth / 2) + (game.width / 2 / scale);
let cy = -cameraY - (playerHeight / 2) + (game.height / 2 / scale);
context.translate(Math.round(cx), Math.round(cy));

You might also want to get rid of the toPrecision(15) stuff around all coordinates, that's not going to help you much, I don't think.

Auston answered 4/8, 2024 at 16:18 Comment(3)
To round the position makes it way worse, as mentioned in comments above. I provided an video based on your solution: imgur.com/a/QeKs3snKimmy
On my machine, it looked subjectively better with this.Auston
I would love to say so but it seems like we running on different specs. As seen in some comments there are differences based on the delta-time which is frame rate related. I think based on the calculated value the rounded number occurs differently but I've already tested it on different devices with the same result for me.Kimmy

© 2022 - 2025 — McMap. All rights reserved.