新SkyWayを使った通信のみで、動けるビデオ通話を実現する

ビデオ会議において、動けないことに不満を感じていました。

ビデオなのでもちろん、自分のカメラ映像の中の自分、そして相手のカメラ映像の中の相手は動いているのですが、あくまでそれはカメラ映像を映すフレームの中での話。
映像枠自体は所定の位置から動いてはくれないのです。
海をも越えるインターネットなのですから、ビデオ会議だって、もっと自由に稼働できてしかるべきではないでしょうか。

ということで、カメラ映像を映す枠ごと移動できるビデオ通話システムを作りました。

## 実装イメージ

自分、そして各参加者のビデオ映像を、それぞれアイコンサイズで表示し、キーボードのカーソルキーによってフィールド内の任意の位置に移動できるようにします。

カーソルキーの押下によって自分の表示座標を更新すると同時に、別の参加者に自分の表示座標を通知します。

前回の記事 [新SkyWay チュートリアルの次に覚えること](https://qiita.com/akid3020/items/4898aebea14a565832cc) に記載のとおり、SkyWayのP2Pルームでは DataStream で任意の情報を送受信することができるため、これを使って座標情報の通信を行うことにします。

**SkyWayのAPIのみ、サーバレスで実現できてしまう。** すごいですね。

## 実装する

React + TypeScript で、Webアプリケーションとして実装しました。

### フィールドエリア

まずはアイコンを表示するフィールドエリアを作っておきます。
フィールドサイズは実際には可変にしましたが、以下ではわかりやすくCSS直書きにしています。

```css
.field-area {    
    position: relative;
    width: 800px;
    height: 400px;
    /* 適当に影とか背景色とか */
    background-color: #F0F0F0;
    box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px;
}
```

html（jsx）のイメージです。

```tsx
return (
  <!-- keydownイベントを受け取るためにtabIndexを指定している -->
  <div className="field-area" tabIndex={-1}>
      <!-- ここにあとでアイコンを配置 -->
  </div>
)
```

### 自分の映像をアイコンとして配置

ローカルのカメラ映像をvideo要素として配置します。

```css
.icon-container {
    position: absolute;    
}

.video {
    object-fit: cover;
    clip-path:circle(50% at 50% 50%);
    width: 90px;
    height: 90px;
}
```

`position: absolute` にすることで、表示位置を座標指定できるようにします。
座標値は動的に変更できるように、CSSではなくインラインスタイルとして渡します。

```tsx
interface IconPosition {
    top: number;
    left: number;
}

const [ myIconPosition, setMyIconPosition ] = useState<IconPosition>({ top:0, left:0 });

const myIconContainerStyle = useMemo<React.CSSProperties>(() => ({
    top: myIconPosition.top,
    left: myIconPosition.left
}),[ myIconPosition ]);

return (
    <div className="field-area" tabIndex={-1}>
        <div className="icon-container" style={myIconContainerStyle}>
            <video className="video" muted playsInline />
        </div>
    </div>
);
```

カメラ映像のvideo要素へのアタッチやSkyWayルームへの配信については↓前々回の記事を参考にしていただければと思います。

https://qiita.com/akid3020/items/69752609714272fb8dec#%E3%83%AB%E3%83%BC%E3%83%A0%E3%81%AB%E5%8F%82%E5%8A%A0

### カーソルキーで移動する

keydownイベントをハンドルして、アイコン座標に反映します。

```ts
// これを field-area の div の onKeyDown に指定
const onKeyDown = useCallback((e:React.KeyboardEvent<HTMLDivElement>) => {
    let h = 0;
    let v = 0;
    
    // 移動量は適当に決める
    if (e.key === "Left" || e.key === "ArrowLeft") {
        h = -8;
    } else if (e.key === "Up" || e.key === "ArrowUp") {
        v = -8;
    } else if (e.key === "Right" || e.key === "ArrowRight") {
        h = 8;
    } else if (e.key === "Down" || e.key === "ArrowDown") {
        v = 8;
    } else {
        return;
    }

    // myIconPositionに反映
    setMyIconPosition(pre => {
        const newPosition: IconPosition = {
            ...pre,
            top: pre.top + v,
            left: pre.left + h
        };

        // 実際には、フィールド領域をはみ出ないように調整を入れる（省略）

        return newPosition;
    });
}, []);
```

それと同時に、DataStreamから他の参加者に座標を通知します。

```ts
// SkyWayStreamFactory.createDataStream() で作っておく
const [ localDataStream, setLocalDataStream ] = useState<LocalDataStream>();

// myIconPositionが更新されたときの処理
useEffect(() => {
    if (localDataStream != null) {
        localDataStream.write(myIconPosition);
    }
}, [ localDataStream, myIconPosition ]);

```

### 他の参加者の座標を反映する

他の参加者から送信された座標情報についても、受け取って画面に反映してやる必要があります。
以下では簡易的に、自分以外のユーザは1人とします。

```ts
// SkyWayRoom.Create または SkyWayRoom.Find で作っておく
const [ room, setRoom ] = useState<P2PRoom>();
// room.join() の戻り値
const [ me, setMe ] = useState<LocalP2PRoomMember>();
// me.subscribe(publication.id) の戻り値に含まれる stream
// (contentType === "data" のもの)
const [ anotherUserDataStream, setAnotherUserDataStream ] = useState<RemoteDataStream>();

// 他ユーザーの座標情報を保持
// （これを自分のアイコンと同様に画面表示用のstyleに反映する）
const [ anotherUserPosition, setAnotherUserPosition ] = useState<IconPosition>({ top:0, left:0 });

useEffect(() => {
    if (anotherUserDataStream != null) {
        // callbackで受信座標を反映する
        anotherUserDataStream.data.onData.add(args => setAnotherUserPosition(args as IconPosition));
    }
}, [ anotherUserDataStream ]);

```

これで動き回れるビデオ会議ができました。

![skyway-move01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/03bef373-49d9-ed1d-82c6-006ceb94fe78.png)
![skyway-move02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/16ce8ba6-79bc-bb96-4a75-1aaec847b219.png)

## 発展編：ボールになる

ここからが本番。

ただカーソルキーの方向に移動するだけでは面白みにかけるので、 **物理演算によって、ボールのようにもっと激しく動けるように** しましょう。

jsの物理演算ライブラリである matter.js を使いましょう。

https://brm.io/matter-js/

### matter.jsの準備

npmでインストールします。

```bash
$ npm i matter-js
$ npm i -D @types/matter-js
```

使うクラスをインポートしておきます。

```ts
import { Engine, Render, Runner, Bodies, Body, Composite, Vector } from "matter-js";
```

エンジンとレンダーを作成。

```ts
const WIDTH = 800;
const HEIGHT = 600;

// field-area のdiv要素を ref で参照しておく
const refField = useRef<HTMLDivElement>(null);

const engine = Engine.create();

const render = Render.create({
    element: refField.current,
    engine:engine,
    options: {
        width: WIDTH,
        height: HEIGHT,
    }
});

Runner.run(engine);
Render.run(render);
```

フィールドに壁と床を作っておきます。

```ts
const ceiling = Bodies.rectangle(WIDTH / 2, 0, WIDTH, 1, { isStatic: true });
const floor = Bodies.rectangle(WIDTH / 2, HEIGHT - 1, WIDTH, 1, { isStatic:true });
const wallL = Bodies.rectangle(0, HEIGHT / 2, 1, HEIGHT, { isStatic:true });
const wallR = Bodies.rectangle(WIDTH - 1, HEIGHT / 2, 1, HEIGHT, { isStatic:true });
Composite.add(engine.world, [ceiling, floor, wallL, wallR]);

```

ざっくりこんな感じで準備完了。

### 物理演算図形の中にvideoを描画する

matter.jsのサンプルコードなどを見ると、円や四角などの単純な図形を描画しているものが多いですが、今回は物理演算で動かす図形の中に、video要素を埋め込みたい。
この場合は、Costom Rendererというのを使うようです。

```ts
const WIDTH = 800;
const HEIGHT = 600;
const CIRCULE_RADIUS = 45;

// 自分のアイコン図形を保持しておく場所
const [ myCircle, setMyCircle ] = useState<Body>;


// 円のパラメータ
const x = WIDTH / 2;
const y = HEIGHT / 2;

// 円を作成
const circle = Bodies.circle(x, y, CIRCULE_RADIUS, {
    // チューニングパラメータ
    restitution: 0.5,
    frictionAir: 0.01,
});

// 保持
setMyCircle(circle);

const myIcon = {
    body: circle,
    // 自分のアイコン（video要素）を配置するコンテナ要素
    elem: refMyIcon.current,
    render() {
        const {x, y} = this.body.position;
        this.elem.style.top = `${y - CIRCULE_RADIUS}px`;
        this.elem.style.left = `${x - CIRCULE_RADIUS}px`;
        this.elem.style.width = `${CIRCULE_RADIUS * 2}px`;
        this.elem.style.height = `${CIRCULE_RADIUS * 2}px`;
        this.elem.style.transform = `rotate(${this.body.angle}rad)`;
    }
}

// 円を画面に追加
Composite.add(engine.world, circle);

// rerender
(function rerender() {
    myIcon.render();
    Engine.update(engine);
    requestAnimationFrame(rerender);
})();
```

### カーソルキーで力を加える

keydownイベントをハンドルして、今度はボールに力を加えます。

```ts
const onKeyDown = useCallback((e:React.KeyboardEvent<HTMLDivElement>) => {

    let force: Vector;

    if (e.key === "Left" || e.key === "ArrowLeft") {
        force = { x: -0.05, y: 0 };
    } else if (e.key === "Up" || e.key === "ArrowUp") {
        force = { x: 0, y: -0.2 };
    } else if (e.key === "Right" || e.key === "ArrowRight") {
        force = { x: 0.05, y: 0 };
    } else if (e.key === "Down" || e.key === "ArrowDown") {
        force = { x: 0, y: 0.02 };
    } else {
        return;
    }

    Body.applyForce(myCircle, myCircle.position, force);

    // 他の参加者にも force の情報を送信する（省略）

}, [ myCircle ]);
```

同様に、他の参加者から送られてきた force を画面のアイコンに反映します。

他の参加者が操作した情報の受信→反映にラグがあるため、先ほどの単純な座標移動と違って、こちらは自分の画面で見えている動きと他の参加者が画面で見ている動きとは完全に一致しません。
たとえば自分の画面では、自分のアイコンの上に他の参加者のアイコンが乗っかっているかもしれませんが、相手が同じものを見ているとは限りません。
だから何だということは無いですが…。

![skyway-move03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/e9d97bb8-e7d8-adc0-a6d7-bf0a4364c994.png)

## こちらで試せます

https://skyway-move.netlify.app/

* キーボード操作のみなので、スマホでは利用できません
* 参加可能人数は2人としています
* ボール運動モードについて
    * Mac (Safari) だと、うまく上にジャンプできないことがあるようです
    * 現在のチューニングだと、あまり全力で動かすと **壁を突き破ってどっかに行く** ことがあるようですが、ご愛嬌ということで(汗

以上になります。

これができると、自分のカメラ映像をアイコンとしてAmong Usみたいなゲームが遊べたり、距離によって声の大きさが変わるビデオ会議ツールができたり、など、アイデアが広がりますね！