仕掛けがあっるっぞ〜。Mr.マリック(あいさつ)

こんにちは。おれです。

みなさん、ナスは好きですか。

そうですか。

おれはそうでもないです。

というわけで、ナス以外を食うゲームを作りました。

リンク : ナスイガイーター

自分のクチを動かしてナス以外のものを食べていこうという遊びです。カメラを使って自分の顔を認識して口を動かして遊びます。

これのつくりかたを書きます。それがおれの趣味だからです。

概要

  • 製作期間 1~2週間
  • 使ったぎぢつ
    • typescript
    • phaser
    • mediapipe

企画

mediapipeというGoogleが提供している機械学習フレームワーク?とかいうのがあります。 これに顔のランドマーク検出機能があり

MediaPipe Studio

なかなかいい精度で顔面を検出できるので、これ使ってなにか遊べないかということで、今回試してみました。

とりあえずクチを使ってなんか食うゲームが簡単だろう。苦手なものを食ったらペナルティにしよう。ウチのオカンがナスを食えないので、ナスを食えないゲームにしよう。ナス以外喰らうもの、ナスイガイーター。という流れです。おわかりか。

開発

というわけで作るものが決まったので、つくります。

顔のランドマーク検出というのは、顔の形を何十個かの点(ポイント)にして検出できるというものです。

口のポイントを取って、それを線で結ぶことで👄クチを描画することができるワケです。

線の描画なので、むかしのアーケートゲームのベクタースキャンっぽい見た目にしたらクールなのではないかと考え、そんな感じを指向することにしました。

こういうやつね

顔の検出

というわけで、そういうプログラムを書きます。 今回はゲームエンジンとしてJavascriptのゲームエンジンPhaserを採用しました。

カメラと連携する必要があるので、Javascriptで書いた方が都合がよいです。

そしてこれがプログラムだオラッ!!

カメラの起動とFaceMesh

カメラの起動の処理がこんなかんじ

    private async startCamera() {
      try {
          const stream = await navigator.mediaDevices.getUserMedia({ video: true });
          this.videoElement.srcObject = stream;
          this.videoElement.play();
 
          const camera = new Camera(this.videoElement, {
              onFrame: async () => {
                  await this.faceMesh.send({ image: this.videoElement });
              },
              width: 480,
              height: 640
          });
          camera.start();
      } catch (error) {
          console.error('カメラの起動に失敗しました:', error);
          alert('このゲームを遊ぶにはカメラの認識が必要です!');
      }
    }

this.faceMeshというのが肝心の顔検出の本体。これにカメラの画像を渡して処理をしてもらう感じです。

faceMeshはこんな感じで定義してあります。

        this.faceMesh = new FaceMesh({
            locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
        });
        this.faceMesh.setOptions({
          maxNumFaces: 1,
          minDetectionConfidence: 0.5,
          minTrackingConfidence: 0.5
        });
 
        this.faceMesh.onResults(this.onResults.bind(this));
    

要約すると、顔が検知されたときOnResultsに検出結果(顔データ)を渡すということをやっています。

OnResults

そしてこれがOnResultsの全文だオラッ

    private onResults(results: Results) {
      if (results.multiFaceLandmarks) {
        results.multiFaceLandmarks.forEach((landmarks) => {
            // くちびるの形を線で描画
            this.currentLandmarks = landmarks;
            this.IsMouthOpen = this.isMouthOpen(landmarks);
            let lipColor = this.IsMouthOpen ? 0xffff00 : 0xff0000; // 黄色または赤色
 
            if(this.isNasuEaten){
              lipColor = 0xff00ff; // ムラサキ
            }
 
            this.drawLips(this.currentLandmarks,lipColor);
            this.updateLipCollider(this.currentLandmarks);
            // 口が開いているかを判定
            if (this.IsMouthOpen) {
                this.logText.setText('口を開いています');
                this.lipCollider.setTint(0xffff00); // 黄色
 
                // 口を開けた際の処理をここに追加
            } else {
                this.logText.setText('口を閉じています');
                this.lipCollider.setTint(0xff0000); // 赤色(元の色に合わせて調整)
 
                // 口を閉じた際の処理をここに追加
            }
 
            //なすを食べてた時はムラサキ
            if(this.isNasuEaten){
              this.lipCollider.setTint(0xff00ff); // ムラサキ
            }
        });
      }else{
        this.logText.setText('ランドマークが取得できませんでした');
      }
    }
 

ごちゃごちゃと書いています。ざっくりの流れは

  • isMouthOpenで口が開いてるかどうか判定
  • this.drawLips で口の線を描画
  • this.updateLipColliderで口の当たり判定を更新
  • 口の開け閉めに連動して口の色を更新

こんな感じです。

isMouthOpen

口が開いているか判定する処理がこれです。

    private isMouthOpen(landmarks: any): boolean {
      /*
       * 口が開いているかどうかを判定するための関数
       * 使用するランドマーク:
       * - 上唇の中央: 13
       * - 下唇の中央: 14
       * - 口の幅: 78 (左端), 308 (右端)
       */
  
      // 上唇と下唇の中央のランドマーク
      const upperLip = landmarks[13];
      const lowerLip = landmarks[14];
  
      // 口の幅を計算
      const mouthLeft = landmarks[78];
      const mouthRight = landmarks[308];
      const mouthWidth = Phaser.Math.Distance.Between(
          mouthLeft.x * this.cameras.main.width,
          mouthLeft.y * this.cameras.main.height,
          mouthRight.x * this.cameras.main.width,
          mouthRight.y * this.cameras.main.height
      );
  
      // 上唇と下唇の垂直距離を計算
      const mouthOpenDistance = Phaser.Math.Distance.Between(
          upperLip.x * this.cameras.main.width,
          upperLip.y * this.cameras.main.height,
          lowerLip.x * this.cameras.main.width,
          lowerLip.y * this.cameras.main.height
      );
  
      // 口の開閉の割合を計算
      const mouthAspectRatio = mouthOpenDistance / mouthWidth;
  
      // 閾値を設定(調整が必要)
      const threshold = 0.35;
  
  
      return mouthAspectRatio > threshold;
    }

もうなんだったのかあまり覚えていません。半分くらいAIが書いてんじゃないかと思う。

唇と上と下のポイントを取って、それがどれぐらい離れているか数値にして、しきい値以上離れていたら開いているということにして結果を返すという感じだと思います。

drawLips

そしてこれがクチビルを線で描画する処理

    private drawLips(landmarks: any, color: number) {
      // 前のフレームの描画をクリア
      this.graphics.clear();
 
      this.graphics.lineStyle(2, color, 1.0);
 
      // 各ペアに対して線を描画
      FACEMESH_LIPS.forEach(pair => {
          const [start, end] = pair;
          const startLandmark = landmarks[start];
          const endLandmark = landmarks[end];
 
          const x1 = (1 - startLandmark.x) * this.cameras.main.width;
          const y1 = startLandmark.y * this.cameras.main.height;
 
          const x2 = (1 - endLandmark.x) * this.cameras.main.width;
          const y2 = endLandmark.y * this.cameras.main.height;
 
          this.graphics.moveTo(x1, y1);
          this.graphics.lineTo(x2, y2);
      });
 
      this.graphics.strokePath();
    }

PhaserのGraphics機能を使って線を描画します。FACEMESH_LIPSに口のポイントの情報が入っているので、それを順繰り回して線を引くという感じです。

updateLipCollider

クチの部分の当たり判定を更新します。判定をつくることでクチがゲームの物理世界に干渉できるわけです。

要は食べ物に当たった時に食うことができます

 
    private updateLipCollider(landmarks: any) {
      // 唇の中心位置とサイズを計算
      const lipPoints = FACEMESH_LIPS.flatMap(pair => [landmarks[pair[0]], landmarks[pair[1]]]);
 
      const xs = lipPoints.map((p: any) => (1 - p.x) * this.cameras.main.width);
      const ys = lipPoints.map((p: any) => p.y * this.cameras.main.height);
 
      const minX = Math.min(...xs);
      const maxX = Math.max(...xs);
      const minY = Math.min(...ys);
      const maxY = Math.max(...ys);
 
      const centerX = (minX + maxX) / 2;
      const centerY = (minY + maxY) / 2;
      const width = maxX - minX;
      const height = maxY - minY;
 
      // colliderの位置とサイズを更新
      this.lipCollider.setPosition(centerX, centerY);
 
      this.logText.setText(`centerX: ${centerX}, \ncenterY: ${centerY}, \nwidth: ${width}, \nheight: ${height}`);
 
      // コンテナの物理ボディを更新
      const body = this.lipCollider.body as Phaser.Physics.Arcade.Body;
      if (body) {
          body.setSize(width, height, true); // サイズを設定し、中心を基準に再配置
          body.updateFromGameObject(); // 物理エンジンに変更を反映
      }
 
    }
 

this.lipColliderという空のSpriteオブジェクトを作り、こいつの場所と大きさを口の大きさ・位置と合わせることで判定を作っているという感じですね。

まとめ

さて、クチを描画して当たり判定をつくることができれば、あとは普通にゲームをつくるのと変わらん感じで作ることができます。

食べ物を配置して、クチと当たった時に食うって処理をすればできあがりなわけですわ。カンタンですね。ヒュー。

と、ここまで作って、あとはもうすぐ完成できるわとダラダラしてたら年末ギリギリになり、年内に公開しようと思ってたので大晦日の夜に超急いでウワーーーーーと完成させたのがことの顛末です。

正月にナスを食わないゲームを作るのはどうなんだ縁起物ちゃうんかとかそういうこと考える暇(いとま)もございませんでした。

ということでみんなで遊ぼうナスイガイーター

ナスイガイーター