uriel avalos

React + Matter.js + Accelerometer Demo

0

0

In this post, we hook up matter.js to the deviceorientation event.

Note: this demo has been tested on mobile Android running Chrome. It may or may not work on IOS Safari.

deviceorientation allows you to tell whether or not a phone has been tilted and by how much. So you can do things like tell the user to "tilt the phone to the left".

The tricky part is that deviceorientation data is jumpy. After some trial and error, I discovered that a low pass filter is your friend.

function lowPassFilter(smoothing: number) {
  let value = 0;

  return (point: number) => {
    value += (point - value) / smoothing;
    return value;
  };
}

smoothing = 30 seems to do the trick.

The second thing is detecting when the device has been rotated left or right and all the objects have fallen to the bottom of the rotated screen.

Technically, the correct approach is to do collision detection but for my game a simpler approach will do. Since there will be no obstacles, all we have to do is check if the user has rotated the phone for a second or so.

This effect sets the tilt state. The only complex part is that the state updater uses a callback---that ensures the effect runs only once on component initialization since it has no dependencies.

const [tilt, setTilt] = useState<"left" | "right" | "rotated">();
const angle = useRef(0);

useEffect(function startTilt() {
  function tiltHandler(event: DeviceOrientationEvent) {
    const gamma = event.gamma ?? 0;
    angle.current = filter(gamma);

    if (angle.current < -10) {
      // start tilting to the left
      // (unless it's already rotated)
      setTilt((t) => (t === "rotated" ? "rotated" : "left"));
    } else if (angle.current > 10) {
      // start tilting to the right
      // (unless it's already rotated)
      setTilt((t) => (t === "rotated" ? "rotated" : "right"));
    }
  }

  if (window.DeviceOrientationEvent) {
    window.addEventListener("deviceorientation", tiltHandler, true);
  }

  return () => {
    window.removeEventListener("deviceorientation", tiltHandler);
  };
}, []);

Then this effect starts a timer:

useEffect(
  function handleRotation() {
    function _rotated() {
      setTilt("rotated");
    }

    let unsubscribe: any;

    if (tilt === "left" || tilt === "right") {
      unsubscribe = setTimeout(_rotated, 1000);
    }

    return () => {
      clearTimeout(unsubscribe);
    };
  },
  [tilt]
);

The Code

import { Bodies, Body, Composite, Engine, Runner } from "matter-js";
import { useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { Text } from "../../../src/styles/styles";

const engine = Engine.create();

engine.gravity.x = 0;
engine.gravity.y = 0;

const runner = Runner.create();

Runner.run(runner, engine);

interface Circle {
  x: number;
  y: number;
}

const Canvas = styled.div<{ rotated: boolean }>`
  width: 100vw;
  height: 100vh;
  position: relative;
  background: ${(p) => (p.rotated ? "red" : "gray")};
`;

const Circle = styled.div`
  background-color: yellow;
  border-radius: 50%;
  box-shadow: 2px 2px;
  position: absolute;
`;

const Wall = styled.div`
  background-color: red;
  position: absolute;
  z-index: -1;
`;

function lowPassFilter(smoothing: number) {
  let value = 0;
  return (point: number) => {
    value += (point - value) / smoothing;
    return value;
  };
}

const filter = lowPassFilter(30);
const filter2 = lowPassFilter(30);

export function ReactMatterDemo2() {
  const ref = useRef<HTMLDivElement>(null);
  const dots = useRef<Circle[]>([]);
  const [, setAnim] = useState(0);

  useEffect(function init() {
    const width = ref.current?.clientWidth ?? 0;
    const height = ref.current?.clientHeight ?? 0;

    const ground = Bodies.rectangle(width / 2, height, width, 25, {
      isStatic: true,
    });
    const ceiling = Bodies.rectangle(width / 2, 0, width, 25, {
      isStatic: true,
    });
    const wallL = Bodies.rectangle(0, height / 2, 25, height, {
      isStatic: true,
    });
    const wallR = Bodies.rectangle(width, height / 2, 25, height, {
      isStatic: true,
    });

    Composite.add(engine.world, [ground, ceiling, wallL, wallR]);
  }, []);

  useEffect(() => {
    let unsubscribe: any;

    function addDot() {
      const width = ref.current?.clientWidth ?? 0;
      const height = ref.current?.clientHeight ?? 0;

      const circ = Bodies.circle(
        Math.random() * width * 0.75 + 50,
        Math.random() * height * 0.75 + 50,
        10
      );
      circ.friction = 0.05;
      circ.frictionAir = 0.00005;
      circ.restitution = 0.25;

      Composite.add(engine.world, circ);

      if (dots.current.length < 2) setTimeout(addDot, 300);
    }

    addDot();

    return () => {
      clearTimeout(unsubscribe);
    };
  }, []);

  useEffect(function triggerAnimation() {
    let unsubscribe: number;

    function animate() {
      let i = 0;
      for (const dot of engine.world.bodies) {
        if (dot.isStatic) continue;

        dots.current[i] = { x: dot.position.x, y: dot.position.y };

        i += 1;
      }

      setAnim((x) => x + 1);

      unsubscribe = requestAnimationFrame(animate);
    }

    unsubscribe = requestAnimationFrame(animate);

    return () => {
      cancelAnimationFrame(unsubscribe);
    };
  }, []);

  const [tilt, setTilt] = useState<"left" | "right" | "rotated">();
  const angle = useRef(0);
  const angle2 = useRef(0);
  const threshold = 25;

  useEffect(function startTilt() {
    function tiltHandler(event: DeviceOrientationEvent) {
      const gamma = event.gamma ?? 0;
      const beta = event.beta ?? 0;
      angle.current = filter(gamma);
      angle2.current = filter2(beta);

      if (angle.current < -threshold) {
        // start tilting to the left
        // (unless it's already rotated)
        setTilt((t) => (t === "rotated" ? "rotated" : "left"));
      } else if (angle.current > threshold) {
        // start tilting to the right
        // (unless it's already rotated)
        setTilt((t) => (t === "rotated" ? "rotated" : "right"));
      }
    }

    if (window.DeviceOrientationEvent) {
      window.addEventListener("deviceorientation", tiltHandler, true);
    }

    return () => {
      window.removeEventListener("deviceorientation", tiltHandler);
    };
  }, []);

  useEffect(
    function updateGravity() {
      if (tilt === "left") {
        engine.gravity.x = -1;
        engine.gravity.y = 0;
      }
      if (tilt === "right") {
        engine.gravity.x = 1;
        engine.gravity.y = 0;
      }
    },
    [tilt]
  );

  useEffect(
    function handleRotation() {
      function _rotated() {
        setTilt("rotated");
      }

      let unsubscribe: any;

      if (tilt === "left" || tilt === "right") {
        unsubscribe = setTimeout(_rotated, 1000);
      }

      return () => {
        clearTimeout(unsubscribe);
      };
    },
    [tilt]
  );

  const [walls, setWalls] = useState<JSX.Element[]>([]);

  useEffect(() => {
    const _walls = engine.world.bodies.filter((bod) => bod.isStatic);

    setWalls(
      _walls.map((body, key) => (
        <Wall
          key={key}
          style={{
            left: body.bounds.min.x,
            top: body.bounds.min.y,
            width: Math.abs(body.bounds.min.x - body.bounds.max.x),
            height: Math.abs(body.bounds.min.y - body.bounds.max.y),
          }}
        />
      ))
    );
  }, []);

  return (
    <Canvas ref={ref} rotated={tilt === "rotated"}>
      {walls}
      <Text>{angle.current}</Text>
      <Text>{angle2.current}</Text>
      <button onClick={() => setTilt(undefined)}>Reset</button>
      {dots.current.map((dot, key) => (
        <Circle
          key={key}
          style={{
            top: dot.y - 10,
            left: dot.x - 10,
            width: "20px",
            height: "20px",
          }}
        />
      ))}
    </Canvas>
  );
}