1

I have been trying to create an animation so that i can control the block with my arrow key and spacebar on my keyboard, I have managed to get the jump working by changing the velocity y figures,however, i am still struggling to move the box to the left and right, I have tried to set the state to true when I have my key down so that when its true it will set my velocity x up and the block will keep going right until i key up, it will return back false and set the velocity x back to 0, but for some reason, this is not working, here are my codes:

 const gravity = 4.5;

const Canvas = () => {
  const { innerWidth: innerwidth, innerHeight: innerheight } = window;
  const canvasRef = useRef();

  const [position, setPosition] = useState({ x: 100, y: 100 });
  const [size, setSize] = useState({ width: 30, height: 30 });
  const [velocity, setVelocity] = useState({ x: 0, y: 0 });
  const [pressRight, setPressRight]= useState(false)
  const [pressLeft, setPressLeft]= useState(false)

  const draw = useCallback((context) => {
    context.fillStyle = 'red';
    context.fillRect(position.x, position.y, size.width, size.height);
  }, [position, size]); 

  const update = useCallback((context, canvas) => {
    draw(context)
    setPosition({ x: position.x, y: position.y += velocity.y })

    if (position.y + size.height + velocity.y <= canvas.height) {
      setVelocity({ x: velocity.x, y: velocity.y += gravity })
    } else {
      setVelocity({ x:velocity.x, y: velocity.y = 0 })
    }
  }, [position, size, velocity]);

  const animate = (context, width, height, canvas) => {
    requestAnimationFrame(() => {
      animate(context, width, height, canvas);
    });
    context.clearRect(0, 0, width, height);
    update(context, canvas);

    if(!pressRight){
      setVelocity({ x: velocity.x = 0 , y: velocity.y})
      console.log("let go")
    }else{
      setVelocity({ x: velocity.x += 5 , y: velocity.y});
      console.log("pressed")
    }
  }


  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    canvas.width= innerwidth - 10;
    canvas.height= innerheight - 10;
    animate(context, canvas.width, canvas.height, canvas)
  }, []);


const handleKeyDown = useCallback(({ key }) => {
  switch (key) {
    case 'ArrowLeft':
      break;
      case 'ArrowRight':
        setPressRight(true)
      break;
    case ' ': // Spacebar
      setVelocity({ x: velocity.x, y: velocity.y -= 50 });
      break
    default:
      console.log(`Unknown key: ${key}`);
    }    
  }, []);

  const handleKeyUp = useCallback(({ key }) => {
    switch (key) {
      case 'ArrowLeft':
        break;
        case 'ArrowRight':
          setPressRight(false)
        break;
      case ' ': 
        break
      default:
        console.log(`Unknown key: ${key}`);
      }    
    }, []);

  return (
    <canvas
      ref={canvasRef}
      tabIndex={-1}
      onKeyDown={handleKeyDown}
      onKeyUp={handleKeyUp}
    />
  );
};

export default Canvas;

Would appreciate any help or suggestion.

tomtom
  • 99
  • 6
  • Don't forget to return a cleanup func from the `useEffect` to clear your animation frame. `velocity.x = 0` mutates state--don't do this. Triggering rerenders 60x per second for the animation might not be a good idea in general. These vars should probably be locally scoped to the animation if they don't need to be part of the React render return. – ggorlen Oct 13 '22 at 18:19
  • hi, thanks for your reply, do you know why the block won't go constant right when i setVelocity to x: velocity.x += 5 – tomtom Oct 14 '22 at 08:51
  • Again, you're modifying state which breaches React's contract with you. You must not reassign any state variables with `=`, `+=` and `-=`. Your RAF setup isn't compatible with rerenders. I suggest looking at reputable examples of how to use canvas with React and spending some time on React fundamentals. – ggorlen Oct 14 '22 at 14:43
  • Maybe check out [How to use this Javascript animation in React.js](https://stackoverflow.com/questions/68228606/how-to-use-this-javascript-animation-in-react-js/68233385#68233385) for example. – ggorlen Oct 14 '22 at 14:51

1 Answers1

1

There are some fundamental misunderstandings here.

The golden rule of React is "never mutate state". This means all assignments to state, like =, += and -= are forbidden.

React state is tied to rerendering the UI. Calls to setState are special, asynchronously communicating to React that it's time to figure out what's changed and rerender the component. Mutating state directly doesn't cause React to get involved, leading to missed renders and other confusing behavior.

On top of state being effectively immutable, state updates are asynchronous, so code like:

setPosition({ x: position.x, y: position.y += velocity.y })

if (position.y + size.height + velocity.y <= canvas.height) {

probably doesn't do what you think, even without the mutation.

The canvas variables don't necessarily need to be in state because they're never used in the rendering code, the returned JSX. They're only used in the canvas animation. See How to use this Javascript animation in React.js for a non-state version (I'll stick to state below to stay closer to your original design). I expect that approach is typically more performant as well, avoiding a ton of object allocations and React bookkeeping in a hot loop.

Always return a function from the useEffect callback to clean up your animation loop.

Putting this all together, here's a sketch of how you might go about setting this up:

const {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} = React;

const deltas = {
  ArrowRight: {x: 1, y: 0},
  ArrowLeft: {x: -1, y: 0},
};

const App = () => {
  const canvasRef = useRef();
  const requestRef = useRef();
  const [time, setTime] = useState(0);
  const [keysDown, setKeysDown] = useState([]);
  const [velocity, setVelocity] = useState({x: 0, y: 0});
  const [position, setPosition] = useState({x: 30, y: 60});
  const size = 50;
  const speed = 5;
  const gravity = 1;
  const drag = 0.9;
  const jumpVelocity = 12;
  const w = 400;
  const h = 150;

  useLayoutEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillRect(position.x, position.y, size, size);

    for (const key of keysDown) {
      if (
        (key === "ArrowUp" || key === "Space") &&
        position.y + size >= h
      ) {
        setVelocity((prev) => ({
          x: prev.x,
          y: -jumpVelocity,
        }));
      }
      else if (key in deltas) {
        const {x, y} = deltas[key];
        setVelocity((prev) => ({
          x: x ? x * speed : prev.x,
          y: y ? y * speed : prev.y,
        }));
      }
    }

    setVelocity((prev) => ({
      x: prev.x * drag,
      y: prev.y + gravity,
    }));
    setPosition((prev) => ({
      x: prev.x + velocity.x,
      y:
        prev.y + velocity.y >= h - size
          ? h - size
          : prev.y + velocity.y,
    }));
  }, [time]);

  const handleKeyDown = (event) => {
    const {code} = event;

    if (!keysDown.includes(code)) {
      setKeysDown((prev) => [code, ...prev]);
    }

    if (code.startsWith("Arrow") || code === "Space") {
      event.preventDefault();
    }
  };
  const handleKeyUp = (event) => {
    setKeysDown((prev) =>
      prev.filter((e) => e !== event.code)
    );

    if (event.code.startsWith("Arrow")) {
      event.preventDefault();
    }
  };

  const update = useCallback((time) => {
    setTime(time);
    requestRef.current = requestAnimationFrame(update);
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    canvas.width = w;
    canvas.height = h;
    requestRef.current = requestAnimationFrame(update);
    return () => {
      cancelAnimationFrame(requestRef.current);
    };
  }, []);

  return (
    <div>
      <p>
        click on the canvas, then use the arrow keys to move
        the box
      </p>
      <canvas
        ref={canvasRef}
        tabIndex={0}
        onKeyDown={handleKeyDown}
        onKeyUp={handleKeyUp}
      ></canvas>
    </div>
  );
};

ReactDOM.createRoot(document.querySelector("#app"))
  .render(<App />);
canvas {
  border: 1px solid black;
}
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<div id="app"></div>

Consider the above as a proof of concept. There's a lot of room for improvement: using hooks to abstract the RAF work, using delta time on updates, factoring out the game logic, perhaps adding the key listener to the whole document rather than just the canvas, etc.

ggorlen
  • 44,755
  • 7
  • 76
  • 106